fuzzyworld

tower / files / src / world / clock.ts

clock.ts

Blame: the commit that last changed each line · file · history

59c12827 2026-09-28 Pavel Safronov1/**
2 * The world clock: one for the whole app, so every view shows the same moment (D-074).
3 *
4 * Hours count from the start of day 0 on the calendar city's clock (sky.ts). The weather
5 * plan, the ships and the sun are all functions of it, and nothing else keeps time. It
1d447a1a 2026-09-28 Pavel Safronov6 * runs on its own animation frame while running, whichever view is open.
7 *
8 * It opens at the calendar city's time now, running, at whatever speed it was last run
9 * at (author, 2026-09-27): the speed and the Light are remembered, the time is not.
59c12827 2026-09-28 Pavel Safronov10 */
11import { type Dial, isDial } from './sky.js'
12
13const KEY = 'tower.clock'
14
15type Listener = () => void
16
17export class WorldClock {
18 /** Hours since day 0 began. */
19 hours = 12
20 running = false
abc0aad0 2026-10-01 Pavel Safronov21 /**
22 * Held while the world is being made (D-096): the clock keeps its running state — the
23 * panel still says it runs — but no time passes, so the sun does not cross a world that is
24 * not there yet. The views read this too, and stand still with it.
25 */
26 held = false
59c12827 2026-09-28 Pavel Safronov27 /** World seconds per real second. */
28 rate = 900
29 /** How bright Tower runs its day. */
30 dial: Dial = 'temperate'
31 private listeners = new Set<Listener>()
32 private last = 0
33 private frame = 0
34
35 constructor(private storage: Pick<Storage, 'getItem' | 'setItem'> | null = safeStorage()) {
36 this.restore()
37 }
38
39 get day(): number {
40 return Math.floor(this.hours / 24)
41 }
42
43 get hourOfDay(): number {
44 return this.hours - this.day * 24
45 }
46
47 subscribe(fn: Listener): () => void {
48 this.listeners.add(fn)
49 return () => this.listeners.delete(fn)
50 }
51
52 set(hours: number): void {
53 this.hours = Math.max(0, hours)
54 this.changed()
55 }
56
57 setDay(day: number): void {
58 this.set(Math.max(0, Math.floor(day)) * 24 + this.hourOfDay)
59 }
60
61 setHourOfDay(hour: number): void {
62 this.set(this.day * 24 + Math.min(24 - 1e-6, Math.max(0, hour)))
63 }
64
65 setDial(dial: Dial): void {
66 this.dial = dial
1d447a1a 2026-09-28 Pavel Safronov67 this.save()
59c12827 2026-09-28 Pavel Safronov68 this.changed()
69 }
70
71 setRate(rate: number): void {
72 this.rate = rate
1d447a1a 2026-09-28 Pavel Safronov73 this.save()
59c12827 2026-09-28 Pavel Safronov74 this.changed()
75 }
76
77 setRunning(on: boolean): void {
78 if (this.running === on) return
79 this.running = on
80 if (on) {
81 this.last = performance.now()
82 this.frame = requestAnimationFrame((t) => this.tick(t))
83 } else {
84 cancelAnimationFrame(this.frame)
85 }
86 this.changed()
87 }
88
abc0aad0 2026-10-01 Pavel Safronov89 setHeld(on: boolean): void {
90 if (this.held === on) return
91 this.held = on
92 this.changed()
93 }
94
59c12827 2026-09-28 Pavel Safronov95 private tick(now: number): void {
96 if (!this.running) return
abc0aad0 2026-10-01 Pavel Safronov97 const dt = this.held ? 0 : Math.min(0.1, (now - this.last) / 1000)
59c12827 2026-09-28 Pavel Safronov98 this.last = now
abc0aad0 2026-10-01 Pavel Safronov99 if (dt > 0) {
100 this.hours += (dt * this.rate) / 3600
101 this.changed()
102 }
59c12827 2026-09-28 Pavel Safronov103 this.frame = requestAnimationFrame((t) => this.tick(t))
104 }
105
106 private changed(): void {
107 for (const fn of this.listeners) fn()
108 }
109
1d447a1a 2026-09-28 Pavel Safronov110 /**
111 * Only what is remembered, and only when it changes. Saving from `changed` was a debounce
112 * that a running clock reset every frame, so it never fired and the speed was forgotten.
113 */
59c12827 2026-09-28 Pavel Safronov114 private save(): void {
115 try {
1d447a1a 2026-09-28 Pavel Safronov116 this.storage?.setItem(KEY, JSON.stringify({ rate: this.rate, dial: this.dial }))
59c12827 2026-09-28 Pavel Safronov117 } catch { /* not remembering the time is survivable */ }
118 }
119
1d447a1a 2026-09-28 Pavel Safronov120 /**
121 * The speed and the Light, every field checked. The time is deliberately not restored:
122 * the clock opens at the city's time now (`start`).
123 */
59c12827 2026-09-28 Pavel Safronov124 private restore(): void {
125 try {
126 const raw = this.storage?.getItem(KEY)
1d447a1a 2026-09-28 Pavel Safronov127 if (!raw) return
128 const s = JSON.parse(raw) as Record<string, unknown>
129 const rate = s['rate']
130 if (typeof rate === 'number' && Number.isFinite(rate) && rate >= 1 && rate <= 1e6) this.rate = rate
131 if (isDial(s['dial'])) this.dial = s['dial']
132 } catch { /* unreadable storage just means the defaults */ }
133 }
134
135 /** Where the clock opens: a moment, and whether it is already running. */
136 start(hours: number, running: boolean): void {
137 this.hours = Math.max(0, hours)
138 this.setRunning(running)
139 this.changed()
59c12827 2026-09-28 Pavel Safronov140 }
141}
142
143function safeStorage(): Storage | null {
144 try {
145 return typeof localStorage === 'undefined' ? null : localStorage
146 } catch {
147 return null
148 }
149}
150
151export const clock = new WorldClock()