diff options
| author | Bobby <[email protected]> | 2026-04-22 08:03:06 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 08:03:06 +0530 |
| commit | e72162b34bddbf04998eea934335e9496b8649f8 (patch) | |
| tree | 121f6442c797fe7a3581f98c631810431a0bcae5 | |
| parent | 4938bd9e1454769f5cc338ba9fab437eaa7e8f58 (diff) | |
| download | hollowdark-e72162b34bddbf04998eea934335e9496b8649f8.tar.xz hollowdark-e72162b34bddbf04998eea934335e9496b8649f8.zip | |
Implement initial load + Begin screens with stub 3s loading pipeline
| -rw-r--r-- | eslint.config.js | 9 | ||||
| -rw-r--r-- | loading/progress.ts | 30 | ||||
| -rw-r--r-- | loading/session.ts | 26 | ||||
| -rw-r--r-- | loading/stub.ts | 42 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 3 | ||||
| -rw-r--r-- | routes/+page.svelte | 58 | ||||
| -rw-r--r-- | ui-lib/components/BeginScreen.svelte | 138 | ||||
| -rw-r--r-- | ui-lib/components/InitialLoadScreen.svelte | 57 | ||||
| -rw-r--r-- | ui-lib/components/ProgressBar.svelte | 34 |
10 files changed, 365 insertions, 33 deletions
diff --git a/eslint.config.js b/eslint.config.js index d010131..37bdda7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ import js from '@eslint/js' import ts from 'typescript-eslint' import svelte from 'eslint-plugin-svelte' +import svelteParser from 'svelte-eslint-parser' import prettier from 'eslint-config-prettier' import globals from 'globals' @@ -52,7 +53,9 @@ export default ts.config( { files: ['**/*.svelte', '**/*.svelte.ts'], languageOptions: { + parser: svelteParser, parserOptions: { + parser: ts.parser, projectService: true, extraFileExtensions: ['.svelte'] } @@ -67,19 +70,19 @@ export default ts.config( selector: "CallExpression[callee.type='MemberExpression'][callee.object.name='Math'][callee.property.name='random']", message: - 'Math.random() is forbidden in gameplay code. Use the seeded RNG from rng/ — determinism is load-bearing (ARCHITECTURE.md §26).' + 'Math.random() is forbidden in gameplay code. Use the seeded RNG.' }, { selector: "CallExpression[callee.type='MemberExpression'][callee.object.name='crypto'][callee.property.name='getRandomValues']", message: - 'crypto.getRandomValues() is forbidden in gameplay code. Use the seeded RNG from rng/.' + 'crypto.getRandomValues() is forbidden in gameplay code. Use the seeded RNG.' }, { selector: "CallExpression[callee.type='MemberExpression'][callee.object.name='Date'][callee.property.name='now']", message: - 'Date.now() is forbidden in gameplay code. Use GameTime from time/ for gameplay logic. Date.now() is permitted only in tests, scripts, and outside the simulation (metadata, logging, performance measurement).' + 'Date.now() is forbidden in gameplay code. Use GameTime. Date.now() is permitted only in tests, scripts, and outside the simulation.' } ], '@typescript-eslint/no-explicit-any': 'error' diff --git a/loading/progress.ts b/loading/progress.ts new file mode 100644 index 0000000..f631757 --- /dev/null +++ b/loading/progress.ts @@ -0,0 +1,30 @@ +import { writable, type Writable } from 'svelte/store' + +/** + * Phase the loading pipeline is in. + * `idle` nothing running yet; percentage is 0. + * `loading` a load is in flight; percentage advances 0 → 1. + * `complete` load finished; percentage is 1. + */ +export type LoadingPhase = 'idle' | 'loading' | 'complete' + +/** Reactive snapshot of the loading pipeline. */ +export interface LoadingProgress { + readonly percentage: number + readonly currentMessage: string + readonly phase: LoadingPhase +} + +const DEFAULT: LoadingProgress = { + percentage: 0, + currentMessage: 'Preparing your reading space', + phase: 'idle' +} + +/** Global store of the initial-load progress. Components subscribe via `$`. */ +export const loadingProgress: Writable<LoadingProgress> = writable(DEFAULT) + +/** Reset the store to its initial state. Useful between sessions in dev. */ +export function resetLoadingProgress(): void { + loadingProgress.set(DEFAULT) +} diff --git a/loading/session.ts b/loading/session.ts new file mode 100644 index 0000000..a1a685b --- /dev/null +++ b/loading/session.ts @@ -0,0 +1,26 @@ +/** + * The three states the Begin screen can render. + * + * `first-ever` no world exists on device yet. Only option is + * to begin (create the first world + character). + * `returning-active` a world exists with a currently-active player + * character. Primary option is to continue them. + * `returning-no-active` a world exists but the current character has + * died (or been ended) without a successor yet. + * Primary option is to pick a successor via the + * continuation flow. + */ +export type BeginState = + | { readonly kind: 'first-ever' } + | { readonly kind: 'returning-active'; readonly characterName: string } + | { readonly kind: 'returning-no-active' } + +/** + * Inspect device-local state and decide which Begin variant to show. The + * real implementation queries IndexedDB for worlds and the currently + * active player character; while persistence is still being wired up this + * returns `first-ever` unconditionally. + */ +export async function detectBeginState(): Promise<BeginState> { + return { kind: 'first-ever' } +} diff --git a/loading/stub.ts b/loading/stub.ts new file mode 100644 index 0000000..99bbd61 --- /dev/null +++ b/loading/stub.ts @@ -0,0 +1,42 @@ +import { loadingProgress } from '@hollowdark/loading/progress' + +const STUB_DURATION_MS = 3000 + +/** + * Stand-in for the real content-manifest fetch and chunk download. Animates + * the loading progress store from 0 to 1 over three seconds of wall-clock + * time, then resolves. Replace with the real pipeline when content loading + * comes online. + */ +export function runStubInitialLoad(): Promise<void> { + loadingProgress.set({ + percentage: 0, + currentMessage: 'Preparing your reading space', + phase: 'loading' + }) + + return new Promise((resolve) => { + const startedAt = performance.now() + + const tick = (): void => { + const elapsed = performance.now() - startedAt + const pct = Math.min(1, elapsed / STUB_DURATION_MS) + const done = pct >= 1 + + loadingProgress.set({ + percentage: pct, + currentMessage: 'Preparing your reading space', + phase: done ? 'complete' : 'loading' + }) + + if (done) { + resolve() + return + } + + requestAnimationFrame(tick) + } + + requestAnimationFrame(tick) + }) +} diff --git a/package.json b/package.json index 55d0e5b..7f1b2f1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "prettier-plugin-svelte": "^3.5.1", "svelte": "^5.55.4", "svelte-check": "^4.4.6", + "svelte-eslint-parser": "^1.6.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.0", "vite": "^8.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2046fa..09fed39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: svelte-check: specifier: ^4.4.6 version: 4.4.6([email protected])([email protected](@typescript-eslint/[email protected]))([email protected]) + svelte-eslint-parser: + specifier: ^1.6.0 + version: 1.6.0([email protected](@typescript-eslint/[email protected])) typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/routes/+page.svelte b/routes/+page.svelte index eeff5b2..b7a0146 100644 --- a/routes/+page.svelte +++ b/routes/+page.svelte @@ -1,35 +1,33 @@ <script lang="ts"> -</script> + import { onMount } from 'svelte' + import BeginScreen from '@hollowdark/ui-lib/components/BeginScreen.svelte' + import InitialLoadScreen from '@hollowdark/ui-lib/components/InitialLoadScreen.svelte' + import { runStubInitialLoad } from '@hollowdark/loading/stub' + import { detectBeginState, type BeginState } from '@hollowdark/loading/session' + + type View = 'loading' | 'begin' -<main class="placeholder"> - <p class="title">Hollowdark</p> - <p class="note">Scaffolding in place.</p> -</main> + let view: View = $state('loading') + let beginState: BeginState = $state({ kind: 'first-ever' }) -<style> - .placeholder { - min-height: 100dvh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-8); - } + onMount(async () => { + await runStubInitialLoad() + beginState = await detectBeginState() + view = 'begin' + }) - .title { - font-family: var(--font-body); - font-size: var(--text-xl); - font-style: italic; - letter-spacing: 2px; - color: var(--color-text); - margin-bottom: var(--space-4); - } + function handleBegin(): void {} + function handleContinue(): void {} + function handleSettings(): void {} +</script> - .note { - font-family: var(--font-ui); - font-size: var(--text-xs); - color: var(--color-text-tertiary); - letter-spacing: 1px; - text-transform: uppercase; - } -</style> +{#if view === 'loading'} + <InitialLoadScreen /> +{:else} + <BeginScreen + state={beginState} + onBegin={handleBegin} + onContinue={handleContinue} + onSettings={handleSettings} + /> +{/if} diff --git a/ui-lib/components/BeginScreen.svelte b/ui-lib/components/BeginScreen.svelte new file mode 100644 index 0000000..c095f61 --- /dev/null +++ b/ui-lib/components/BeginScreen.svelte @@ -0,0 +1,138 @@ +<script lang="ts"> + import type { BeginState } from '@hollowdark/loading/session' + + interface Props { + state: BeginState + appVersion?: string + onBegin: () => void + onContinue?: () => void + onSettings: () => void + } + + let { + state, + appVersion = 'v 0.1', + onBegin, + onContinue, + onSettings + }: Props = $props() + + function handleContinue(): void { + onContinue?.() + } +</script> + +<section class="begin"> + <div class="top"> + <p class="title">Hollowdark</p> + <p class="subtitle">A life simulation</p> + </div> + + <div class="actions"> + {#if state.kind === 'first-ever'} + <button class="primary" onclick={onBegin}>Begin</button> + {:else if state.kind === 'returning-active'} + <button class="primary" onclick={handleContinue}> + Continue {state.characterName}'s life + </button> + <button class="secondary" onclick={onBegin}>Begin a new life</button> + {:else} + <button class="primary" onclick={handleContinue}>Continue</button> + <button class="secondary" onclick={onBegin}>Begin a new life</button> + {/if} + + <button class="tertiary" onclick={onSettings}>Settings</button> + </div> + + <p class="version">{appVersion}</p> +</section> + +<style> + .begin { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12); + position: relative; + text-align: center; + } + + .top { + margin-bottom: 120px; + } + + .title { + font-family: var(--font-body); + font-size: 38px; + font-style: italic; + font-weight: 400; + letter-spacing: 2px; + color: var(--color-text); + margin-bottom: var(--space-6); + } + + .subtitle { + font-family: var(--font-ui); + font-size: 11px; + color: var(--color-text-tertiary); + letter-spacing: 2px; + text-transform: uppercase; + } + + .actions { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-8); + } + + .primary { + font-family: var(--font-body); + font-size: var(--text-lg); + color: var(--color-accent); + letter-spacing: 0.5px; + transition: color var(--transition-fast); + } + + .primary:hover { + color: var(--color-text); + } + + .secondary { + font-family: var(--font-body); + font-size: 15px; + color: var(--color-text-secondary); + letter-spacing: 0.3px; + transition: color var(--transition-fast); + } + + .secondary:hover { + color: var(--color-text); + } + + .tertiary { + font-family: var(--font-body); + font-size: 13px; + color: var(--color-text-tertiary); + letter-spacing: 0.3px; + transition: color var(--transition-fast); + } + + .tertiary:hover { + color: var(--color-text-secondary); + } + + .version { + position: absolute; + bottom: var(--space-6); + left: 0; + right: 0; + text-align: center; + font-family: var(--font-ui); + font-size: 10px; + color: #3d382f; + letter-spacing: 1px; + } +</style> diff --git a/ui-lib/components/InitialLoadScreen.svelte b/ui-lib/components/InitialLoadScreen.svelte new file mode 100644 index 0000000..105d283 --- /dev/null +++ b/ui-lib/components/InitialLoadScreen.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import ProgressBar from '@hollowdark/ui-lib/components/ProgressBar.svelte' + import { loadingProgress } from '@hollowdark/loading/progress' + + const progress = $derived($loadingProgress) +</script> + +<section class="initial-load"> + <p class="title">Hollowdark</p> + + <div class="bar"> + <ProgressBar value={progress.percentage} /> + </div> + + <p class="message">{progress.currentMessage}</p> + <p class="note">This will happen once. It will not happen again.</p> +</section> + +<style> + .initial-load { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12); + text-align: center; + } + + .title { + font-family: var(--font-body); + font-size: 32px; + font-style: italic; + font-weight: 400; + letter-spacing: 1px; + margin-bottom: 120px; + } + + .bar { + margin-bottom: var(--space-6); + } + + .message { + font-family: var(--font-ui); + font-size: 12px; + color: var(--color-text-secondary); + letter-spacing: 0.5px; + margin-bottom: var(--space-2); + } + + .note { + font-family: var(--font-ui); + font-size: 11px; + color: var(--color-text-tertiary); + letter-spacing: 0.3px; + } +</style> diff --git a/ui-lib/components/ProgressBar.svelte b/ui-lib/components/ProgressBar.svelte new file mode 100644 index 0000000..21c9e68 --- /dev/null +++ b/ui-lib/components/ProgressBar.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + interface Props { + value: number + width?: number + } + + let { value, width = 320 }: Props = $props() + + const percentage = $derived(Math.max(0, Math.min(1, value)) * 100) +</script> + +<div class="progress-track" style:width="{width}px"> + <div class="progress-fill" style:width="{percentage}%"></div> +</div> + +<style> + .progress-track { + height: 2px; + background: rgba(232, 226, 213, 0.06); + border-radius: 1px; + position: relative; + overflow: hidden; + } + + .progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--color-accent); + border-radius: 1px; + transition: width 300ms ease-out; + } +</style> |
