aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 08:03:06 +0530
committerBobby <[email protected]>2026-04-22 08:03:06 +0530
commite72162b34bddbf04998eea934335e9496b8649f8 (patch)
tree121f6442c797fe7a3581f98c631810431a0bcae5
parent4938bd9e1454769f5cc338ba9fab437eaa7e8f58 (diff)
downloadhollowdark-e72162b34bddbf04998eea934335e9496b8649f8.tar.xz
hollowdark-e72162b34bddbf04998eea934335e9496b8649f8.zip
Implement initial load + Begin screens with stub 3s loading pipeline
-rw-r--r--eslint.config.js9
-rw-r--r--loading/progress.ts30
-rw-r--r--loading/session.ts26
-rw-r--r--loading/stub.ts42
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml3
-rw-r--r--routes/+page.svelte58
-rw-r--r--ui-lib/components/BeginScreen.svelte138
-rw-r--r--ui-lib/components/InitialLoadScreen.svelte57
-rw-r--r--ui-lib/components/ProgressBar.svelte34
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
+ 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>