herd / files / src / game / UI.ts
UI.ts
| 1 | import type { GameState } from './GameState.ts' |
| 2 | import { LEVELS } from '../levels/LevelConfig.ts' |
| 3 | |
| 4 | export 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 | |
| 10 | export 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 | } |