fuzzyworld

herd / files / src / game / UI.ts

UI.ts

6.5 KB · history · blame

1import type { GameState } from './GameState.ts'
2import { LEVELS } from '../levels/LevelConfig.ts'
3
4export function formatTime(seconds: number): string {
5 const m = Math.floor(seconds / 60)
6 const s = Math.floor(seconds % 60)
7 return `${m}:${s.toString().padStart(2, '0')}`
8}
9
10export class UI {
11 private state: GameState
12
13 private screenLevelSelect: HTMLElement
14 private levelGrid: HTMLElement
15 private hud: HTMLElement
16 private hudCounter: HTMLElement
17 private hudTimer: HTMLElement
18 private timerFill: HTMLElement
19 private timerText: HTMLElement
20 private hudShrink: HTMLElement
21 private hudLevelName: HTMLElement
22 private screenEnd: HTMLElement
23 private endTitle: HTMLElement
24 private endSub: HTMLElement
25 private endTime: HTMLElement
26 private endRecord: HTMLElement
27 private endSubtitle: HTMLElement
28
29 private levelBtns: HTMLButtonElement[] = []
30
31 constructor(state: GameState, canvasEl: HTMLCanvasElement) {
32 this.state = state
33
34 this.screenLevelSelect = this.el('#screen-level-select')
35 this.levelGrid = this.el('#level-grid')
36 this.hud = this.el('#hud')
37 this.hudCounter = this.el('#hud-counter')
38 this.hudTimer = this.el('#hud-timer')
39 this.timerFill = this.el('#timer-fill')
40 this.timerText = this.el('#timer-text')
41 this.hudShrink = this.el('#hud-shrink')
42 this.hudLevelName = this.el('#hud-level-name')
43 this.screenEnd = this.el('#screen-end')
44 this.endTitle = this.el('#end-title')
45 this.endSub = this.el('#end-sub')
46 this.endTime = this.el('#end-time')
47 this.endRecord = this.el('#end-record')
48 this.endSubtitle = this.el('#end-subtitle')
49
50 this.buildLevelGrid()
51 this.wireEvents(canvasEl)
52 }
53
54 private el(selector: string): HTMLElement {
55 return document.querySelector(selector) as HTMLElement
56 }
57
58 private buildLevelGrid() {
59 this.levelGrid.innerHTML = ''
60 this.levelBtns = []
61 LEVELS.forEach((level, i) => {
62 const btn = document.createElement('button')
63 btn.className = 'level-btn locked'
64 btn.innerHTML = `
65 <span class="lv-lock">🔒</span>
66 <span class="lv-num">${level.id}</span>
67 <span class="lv-name">${level.title}</span>
68 <span class="lv-best"></span>
69 `
70 btn.addEventListener('click', () => {
71 if (i <= this.state.unlockedUpTo) this.state.startLevel(i)
72 })
73 this.levelGrid.appendChild(btn)
74 this.levelBtns.push(btn)
75 })
76 }
77
78 private wireEvents(canvasEl: HTMLCanvasElement) {
79 this.el('#btn-menu').addEventListener('click', () => this.state.goToMenu())
80
81 this.screenEnd.addEventListener('click', () => this.state.advance())
82
83 canvasEl.addEventListener('pointerdown', (e: PointerEvent) => {
84 if (this.state.phase !== 'PLAYING') return
85 const rect = canvasEl.getBoundingClientRect()
86 this.state.setDogTarget(e.clientX - rect.left, e.clientY - rect.top)
87 })
88
89 window.addEventListener('keydown', (e: KeyboardEvent) => {
90 if (e.key === 'Escape') this.state.goToMenu()
91 })
92 }
93
94 update() {
95 const { phase } = this.state
96 const onGame = phase === 'PLAYING' || phase === 'WIN' || phase === 'FAIL'
97
98 this.screenLevelSelect.classList.toggle('hidden', phase !== 'LEVEL_SELECT')
99 this.hud.classList.toggle('hidden', !onGame)
100 this.screenEnd.classList.toggle('hidden', phase !== 'WIN' && phase !== 'FAIL')
101
102 if (phase === 'LEVEL_SELECT') this.updateLevelGrid()
103 if (onGame) this.updateHUD()
104 if (phase === 'WIN' || phase === 'FAIL') this.updateEndScreen()
105 }
106
107 private updateLevelGrid() {
108 LEVELS.forEach((_, i) => {
109 const btn = this.levelBtns[i]!
110 const locked = i > this.state.unlockedUpTo
111 const best = this.state.bestTimes[i]
112
113 btn.classList.toggle('locked', locked)
114
115 const lockEl = btn.querySelector('.lv-lock') as HTMLElement
116 const numEl = btn.querySelector('.lv-num') as HTMLElement
117 lockEl.classList.toggle('hidden', !locked)
118 numEl.classList.toggle('hidden', locked)
119
120 const bestEl = btn.querySelector('.lv-best') as HTMLElement
121 bestEl.textContent = best !== null ? formatTime(best) : ''
122 })
123 }
124
125 private updateHUD() {
126 const { config: cfg, inPen, sheepCount, elapsed, lastShrinkAt } = this.state
127 if (!cfg) return
128
129 const hasSlack = cfg.winThreshold < sheepCount
130 this.hudCounter.innerHTML = `🐑 ${inPen} / ${cfg.winThreshold}${
131 hasSlack ? `<small>(${sheepCount} total)</small>` : ''
132 }`
133
134 if (cfg.timeLimitSeconds !== null) {
135 this.hudTimer.classList.remove('hidden')
136 const remaining = Math.max(0, cfg.timeLimitSeconds - elapsed)
137 const fraction = remaining / cfg.timeLimitSeconds
138 const color = fraction > 0.4 ? '#5dde5d' : fraction > 0.2 ? '#f0a020' : '#e03030'
139 this.timerFill.style.width = `${fraction * 100}%`
140 this.timerFill.style.backgroundColor = color
141 this.timerText.textContent = `${Math.ceil(remaining)}s`
142 } else {
143 this.hudTimer.classList.add('hidden')
144 }
145
146 if (cfg.penShrinksEverySeconds !== null) {
147 this.hudShrink.classList.remove('hidden')
148 const nextShrink = (lastShrinkAt + 1) * cfg.penShrinksEverySeconds
149 const shrinkIn = Math.max(0, nextShrink - elapsed)
150 this.hudShrink.textContent = `Pen shrinks in ${Math.ceil(shrinkIn)}s`
151 this.hudShrink.classList.toggle('urgent', shrinkIn < 8)
152 } else {
153 this.hudShrink.classList.add('hidden')
154 }
155
156 this.hudLevelName.textContent = `Level ${cfg.id}: ${cfg.title}`
157 }
158
159 private updateEndScreen() {
160 const { phase, levelIndex, elapsed, bestTimes, isNewRecord, config: cfg } = this.state
161 const win = phase === 'WIN'
162
163 this.endTitle.textContent = win ? 'Level Complete!' : "Time's Up!"
164 this.endTitle.style.color = win ? '#7eff7e' : '#ff7e7e'
165 this.endSub.textContent = win
166 ? (levelIndex + 1 < LEVELS.length ? 'Tap to continue →' : 'You finished the game!')
167 : 'Tap to try again'
168
169 if (win) {
170 const best = bestTimes[levelIndex]
171 const timeStr = formatTime(elapsed)
172 const bestStr = best !== null ? formatTime(best) : null
173 this.endTime.textContent = `Time: ${timeStr}${bestStr ? ` • Best: ${bestStr}` : ''}`
174 this.endTime.classList.remove('hidden')
175 } else {
176 this.endTime.classList.add('hidden')
177 }
178
179 this.endRecord.classList.toggle('hidden', !(win && isNewRecord))
180
181 if (win && cfg?.subtitle) {
182 this.endSubtitle.textContent = cfg.subtitle
183 this.endSubtitle.classList.remove('hidden')
184 } else {
185 this.endSubtitle.classList.add('hidden')
186 }
187 }
188}