herd / files / src / game / UI.ts
UI.ts
82e8c064 2026-05-18 Pavel Safronov | 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 | } |