herd / commit
Refactor UI from canvas to DOM
index.html | 162 ++++++++++++++++++++++- src/game/GameState.ts | 355 ++++++++------------------------------------------ src/game/UI.ts | 188 ++++++++++++++++++++++++++ src/main.ts | 14 +- 4 files changed, 405 insertions(+), 314 deletions(-) diff --git a/index.html b/index.html index 4e70401..eb15a58 100644 --- a/index.html +++ b/index.html @@ -8,11 +8,171 @@ <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 100%; height: 100%; overflow: hidden; background: #2d5a1b; } - canvas { display: block; } + + canvas { display: block; position: absolute; top: 0; left: 0; } + + /* ── UI overlay ───────────────────────────────────────────────── */ + #ui { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + font-family: system-ui, sans-serif; + color: #fff; + pointer-events: none; + } + + .screen { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 18px; + pointer-events: all; + } + + .hidden { display: none !important; } + + /* ── Level select ─────────────────────────────────────────────── */ + #screen-level-select h1 { + font-size: clamp(2rem, 6vw, 3.5rem); + font-weight: bold; + text-shadow: 0 2px 8px rgba(0,0,0,0.35); + } + + #screen-level-select .tagline { + color: rgba(255,255,255,0.75); + font-size: clamp(0.85rem, 2.4vw, 1.1rem); + margin-top: -6px; + } + + #level-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + padding: 0 20px; + max-width: 860px; + width: 100%; + } + + .level-btn { + background: rgba(255,255,255,0.15); + border: none; border-radius: 10px; + color: #fff; + cursor: pointer; + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 10px 6px; min-height: 88px; gap: 3px; + pointer-events: all; + transition: background 0.15s; + font-family: inherit; + } + .level-btn:hover:not(.locked) { background: rgba(255,255,255,0.28); } + .level-btn.locked { background: rgba(255,255,255,0.06); cursor: default; } + + .level-btn .lv-lock { font-size: clamp(1.2rem, 2.5vw, 1.6rem); color: rgba(255,255,255,0.25); line-height: 1; } + .level-btn .lv-num { font-size: clamp(1rem, 2.5vw, 1.5rem); font-weight: bold; line-height: 1; } + .level-btn .lv-name { font-size: clamp(0.6rem, 1.1vw, 0.72rem); color: rgba(255,255,255,0.65); text-align: center; line-height: 1.2; } + .level-btn.locked .lv-name { color: rgba(255,255,255,0.2); } + .level-btn .lv-best { font-size: clamp(0.55rem, 1vw, 0.68rem); color: rgba(255,255,255,0.45); } + + #level-select-footer { + display: flex; flex-direction: column; align-items: center; gap: 3px; + margin-top: 4px; + } + #level-select-footer a { + color: rgba(255,255,255,0.45); font-size: 11px; text-decoration: none; pointer-events: all; + } + #level-select-footer a:hover { color: rgba(255,255,255,0.75); } + + /* ── HUD ──────────────────────────────────────────────────────── */ + #hud { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + pointer-events: none; + } + + #hud-counter { + position: absolute; top: 12px; left: 12px; + background: rgba(0,0,0,0.45); border-radius: 8px; + padding: 7px 14px; font-size: 16px; font-weight: bold; white-space: nowrap; + } + #hud-counter small { + font-size: 11px; font-weight: normal; color: rgba(255,255,255,0.45); margin-left: 4px; + } + + #hud-timer { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + display: flex; flex-direction: column; align-items: center; gap: 4px; + } + #timer-track { + width: 160px; height: 10px; + background: rgba(0,0,0,0.4); border-radius: 5px; overflow: hidden; + } + #timer-fill { height: 100%; border-radius: 4px; transition: background-color 0.5s; } + #timer-text { font-size: 12px; } + + #hud-shrink { + position: absolute; top: 12px; right: 12px; + background: rgba(0,0,0,0.45); border-radius: 8px; + padding: 7px 14px; font-size: 13px; white-space: nowrap; + transition: background 0.3s; + } + #hud-shrink.urgent { background: rgba(220,50,50,0.85); } + + #hud-level-name { + position: absolute; bottom: 10px; left: 12px; + color: rgba(255,255,255,0.6); font-size: 11px; + } + + #btn-menu { + position: absolute; bottom: 12px; right: 12px; + background: rgba(0,0,0,0.45); color: #fff; + border: none; border-radius: 8px; padding: 6px 16px; + font-size: 13px; cursor: pointer; font-family: inherit; pointer-events: all; + } + #btn-menu:hover { background: rgba(0,0,0,0.65); } + + /* ── End screen ───────────────────────────────────────────────── */ + #screen-end { background: rgba(0,0,0,0.55); gap: 14px; text-align: center; cursor: pointer; } + + #end-title { font-size: clamp(2rem, 7vw, 3.5rem); font-weight: bold; } + #end-sub { font-size: clamp(1rem, 3.5vw, 1.4rem); } + #end-time { color: rgba(255,255,255,0.7); font-size: clamp(0.85rem, 2.5vw, 1.05rem); } + #end-record { color: #ffd700; font-size: clamp(0.85rem, 2.4vw, 1rem); font-weight: bold; } + #end-subtitle { color: rgba(255,255,255,0.6); font-style: italic; font-size: clamp(0.75rem, 2.2vw, 0.95rem); max-width: 480px; padding: 0 16px; } </style> </head> <body> <canvas id="game"></canvas> + + <div id="ui"> + <!-- Level select --> + <div id="screen-level-select" class="screen"> + <h1>Herd</h1> + <p class="tagline">Tap a level to begin</p> + <div id="level-grid"></div> + <footer id="level-select-footer"> + <a href="https://gitlab.com/PavelSafronov/herd" target="_blank" rel="noopener">gitlab.com/PavelSafronov/herd</a> + <a href="https://www.flaticon.com/authors/icongeek26/outline?author_id=296&type=standard" target="_blank" rel="noopener" style="font-size:10px;opacity:0.6">Icon by Icongeek26 via Flaticon</a> + </footer> + </div> + + <!-- HUD (visible during PLAYING / WIN / FAIL) --> + <div id="hud" class="hidden"> + <div id="hud-counter"></div> + <div id="hud-timer" class="hidden"> + <div id="timer-track"><div id="timer-fill"></div></div> + <span id="timer-text"></span> + </div> + <div id="hud-shrink" class="hidden"></div> + <div id="hud-level-name"></div> + <button id="btn-menu">☰ Menu</button> + </div> + + <!-- End screen --> + <div id="screen-end" class="screen hidden"> + <h2 id="end-title"></h2> + <p id="end-sub"></p> + <p id="end-time" class="hidden"></p> + <p id="end-record" class="hidden">★ New best time!</p> + <p id="end-subtitle" class="hidden"></p> + </div> + </div> + <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/src/game/GameState.ts b/src/game/GameState.ts index 2822bfc..479032d 100644 --- a/src/game/GameState.ts +++ b/src/game/GameState.ts @@ -5,55 +5,77 @@ import { updateBoids } from '../ai/Boids.ts' import { LEVELS, type LevelConfig } from '../levels/LevelConfig.ts' import type { Canvas } from './Canvas.ts' -type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL' +export type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL' -const PEN_SHRINK_RATE = 0.1 // 10% per interval +const PEN_SHRINK_RATE = 0.1 const MIN_PEN_FRACTION = 0.05 export class GameState { - private phase: Phase = 'LEVEL_SELECT' - private levelIndex = 0 - private config!: LevelConfig + phase: Phase = 'LEVEL_SELECT' + levelIndex = 0 + config!: LevelConfig private sheep: Sheep[] = [] private dog!: Dog private pen!: Pen private obstacles: Array<{ x: number; y: number; r: number }> = [] - private scale = 1 // min(W,H) / BASE_DIM — all pixel constants multiply by this - private elapsed = 0 // seconds since level start - private endMessageTimer = 0 + scale = 1 + elapsed = 0 + endMessageTimer = 0 - // pen shrink tracking - private lastShrinkAt = 0 + lastShrinkAt = 0 private penW0 = 0 private penH0 = 0 - private unlockedUpTo = 0 // highest level index the player may enter + unlockedUpTo = 0 + bestTimes: (number | null)[] = Array(LEVELS.length).fill(null) + isNewRecord = false + inPen = 0 // sheep count inside pen, cached every update() tick private canvas: Canvas + constructor(canvas: Canvas) { this.canvas = canvas - const saved = localStorage.getItem('herd_progress') - if (saved !== null) this.unlockedUpTo = parseInt(saved, 10) + const savedProgress = localStorage.getItem('herd_progress') + if (savedProgress !== null) this.unlockedUpTo = parseInt(savedProgress, 10) + const savedTimes = localStorage.getItem('herd_times') + if (savedTimes !== null) this.bestTimes = JSON.parse(savedTimes) as (number | null)[] } - get currentDog() { return this.dog } + get sheepCount(): number { return this.sheep.length } goToMenu() { if (this.phase !== 'LEVEL_SELECT') this.phase = 'LEVEL_SELECT' } + // Advances past WIN/FAIL screen (0.5 s debounce to prevent accidental double-tap). + advance() { + if (this.phase === 'WIN') { + if (this.endMessageTimer < 0.5) return + if (this.levelIndex + 1 < LEVELS.length) { + this.startLevel(this.levelIndex + 1) + } else { + this.phase = 'LEVEL_SELECT' + } + } else if (this.phase === 'FAIL') { + if (this.endMessageTimer < 0.5) return + this.startLevel(this.levelIndex) + } + } + + setDogTarget(x: number, y: number) { + this.dog.setTarget(x, y) + } + startLevel(index: number) { this.levelIndex = index this.config = LEVELS[index]! const cfg = this.config const { width: W, height: H } = this.canvas - // Reference dimension: all boids/entity constants were tuned at minDim=600. this.scale = Math.min(W, H) / 600 - // resolve pen rect from fractions const pen = cfg.penRect this.pen = new Pen( pen.x * W, pen.y * H, @@ -63,13 +85,11 @@ export class GameState { this.penW0 = this.pen.w this.penH0 = this.pen.h - // resolve static obstacles const minDim = Math.min(W, H) this.obstacles = cfg.obstacles.map(o => ({ x: o.x * W, y: o.y * H, r: o.r * minDim, })) - // spawn sheep this.sheep = [] const overrideMap = new Map(cfg.sheepOverrides.map(o => [o.index, o])) let sheepIdx = 0 @@ -86,15 +106,13 @@ export class GameState { } } - // place dog at a neutral position away from the pen this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale) - if (cfg.sheepSpeedMult) { - // speed mult is applied in boids via a per-level constant injected below - } this.elapsed = 0 this.lastShrinkAt = 0 this.endMessageTimer = 0 + this.isNewRecord = false + this.inPen = 0 this.phase = 'PLAYING' } @@ -110,19 +128,15 @@ export class GameState { const cfg = this.config const { width: W, height: H } = this.canvas - // activate dynamic obstacles const minDim = Math.min(W, H) for (const dobs of cfg.dynamicObstacles) { if (this.elapsed >= dobs.appearsAtSeconds) { - const key = `${dobs.x},${dobs.y}` if (!this.obstacles.some(o => Math.abs(o.x - dobs.x * W) < 1)) { this.obstacles.push({ x: dobs.x * W, y: dobs.y * H, r: dobs.r * minDim }) } - void key } } - // pen shrink if (cfg.penShrinksEverySeconds !== null) { const intervals = Math.floor(this.elapsed / cfg.penShrinksEverySeconds) if (intervals > this.lastShrinkAt) { @@ -131,7 +145,6 @@ export class GameState { const factor = Math.pow(1 - PEN_SHRINK_RATE, shrinks) const newW = Math.max(this.penW0 * MIN_PEN_FRACTION, this.pen.w * factor) const newH = Math.max(this.penH0 * MIN_PEN_FRACTION, this.pen.h * factor) - // shrink toward center this.pen.x += (this.pen.w - newW) / 2 this.pen.y += (this.pen.h - newH) / 2 this.pen.w = newW @@ -139,49 +152,45 @@ export class GameState { } } - // update entities this.dog.update(dt) const speedMult = cfg.sheepSpeedMult ?? 1 updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale) - // count sheep in pen - const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length + this.inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length - // check win - if (inPen >= cfg.winThreshold) { + if (this.inPen >= cfg.winThreshold) { this.phase = 'WIN' if (this.levelIndex + 1 > this.unlockedUpTo) { this.unlockedUpTo = this.levelIndex + 1 localStorage.setItem('herd_progress', String(this.unlockedUpTo)) } + const prev = this.bestTimes[this.levelIndex] + if (prev === null || this.elapsed < prev) { + this.bestTimes[this.levelIndex] = this.elapsed + this.isNewRecord = true + localStorage.setItem('herd_times', JSON.stringify(this.bestTimes)) + } return } - // check fail (timer) if (cfg.timeLimitSeconds !== null && this.elapsed >= cfg.timeLimitSeconds) { this.phase = 'FAIL' } - // check lose — pen too small for last-pasture if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) { this.phase = 'FAIL' } } - render(alpha: number) { + render(_alpha: number) { const ctx = this.canvas.ctx const { width: W, height: H } = this.canvas - // field + pre-baked grass texture this.canvas.drawField() - if (this.phase === 'LEVEL_SELECT') { - this.drawLevelSelect(ctx, W, H) - return - } + if (this.phase === 'LEVEL_SELECT') return - // obstacles for (const obs of this.obstacles) { ctx.fillStyle = '#8b7355' ctx.beginPath() @@ -192,33 +201,21 @@ export class GameState { ctx.stroke() } - // pen - const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length - this.pen.draw(ctx, Math.min(1, inPen / this.config.winThreshold), this.scale) + this.pen.draw(ctx, Math.min(1, this.inPen / this.config.winThreshold), this.scale) - // dark mode overlay if (this.config.darkMode) { this.applyDarkMode(ctx, W, H) } - // sheep for (const s of this.sheep) { if (this.config.darkMode) { const dSq = s.pos.distanceSqTo(this.dog.pos) - if (dSq > (120 * this.scale) ** 2) continue // invisible outside torch radius + if (dSq > (120 * this.scale) ** 2) continue } s.draw(ctx, this.scale) } - // dog this.dog.draw(ctx, this.scale) - - // HUD - this.drawHUD(ctx, W, H, inPen, alpha) - - if (this.phase === 'WIN' || this.phase === 'FAIL') { - this.drawEndScreen(ctx, W, H) - } } private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) { @@ -232,11 +229,9 @@ export class GameState { ctx.save() ctx.globalCompositeOperation = 'source-atop' - // fill dark overlay first ctx.fillStyle = 'rgba(0,0,0,0.88)' ctx.fillRect(0, 0, W, H) - // punch out torch circle ctx.globalCompositeOperation = 'destination-out' ctx.fillStyle = gradient ctx.beginPath() @@ -245,250 +240,4 @@ export class GameState { ctx.restore() } - - // Returns the menu button rect so handleTap can hit-test it consistently. - private menuButtonRect(W: number, H: number) { - const bW = 76, bH = 28 - return { x: W - bW - 12, y: H - bH - 12, w: bW, h: bH } - } - - private drawHUD(ctx: CanvasRenderingContext2D, W: number, H: number, inPen: number, _alpha: number) { - const cfg = this.config - - // sheep counter - const hasSlack = cfg.winThreshold < this.sheep.length - const counterW = hasSlack ? 185 : 130 - ctx.fillStyle = 'rgba(0,0,0,0.45)' - ctx.beginPath() - roundRect(ctx, 12, 12, counterW, 38, 8) - ctx.fill() - - ctx.fillStyle = '#fff' - ctx.font = 'bold 16px system-ui' - ctx.textAlign = 'left' - ctx.fillText(`🐑 ${inPen} / ${cfg.winThreshold}`, 20, 36) - - if (hasSlack) { - ctx.fillStyle = 'rgba(255,255,255,0.45)' - ctx.font = '11px system-ui' - ctx.fillText(`(${this.sheep.length} total)`, 108, 36) - } - - // timer bar - if (cfg.timeLimitSeconds !== null) { - const remaining = Math.max(0, cfg.timeLimitSeconds - this.elapsed) - const fraction = remaining / cfg.timeLimitSeconds - const barW = 160, barH = 10, barX = W / 2 - barW / 2, barY = 14 - - ctx.fillStyle = 'rgba(0,0,0,0.4)' - ctx.beginPath() - roundRect(ctx, barX - 2, barY - 2, barW + 4, barH + 4, 5) - ctx.fill() - - const barColor = fraction > 0.4 ? '#5dde5d' : fraction > 0.2 ? '#f0a020' : '#e03030' - ctx.fillStyle = barColor - ctx.beginPath() - roundRect(ctx, barX, barY, barW * fraction, barH, 4) - ctx.fill() - - // time remaining text - ctx.fillStyle = '#fff' - ctx.font = '12px system-ui' - ctx.textAlign = 'center' - ctx.fillText(`${Math.ceil(remaining)}s`, W / 2, barY + barH + 14) - } - - // pen shrink warning for level 10 - if (cfg.penShrinksEverySeconds !== null) { - const nextShrink = (this.lastShrinkAt + 1) * cfg.penShrinksEverySeconds - const shrinkIn = Math.max(0, nextShrink - this.elapsed) - ctx.fillStyle = shrinkIn < 8 ? 'rgba(220,50,50,0.85)' : 'rgba(0,0,0,0.45)' - ctx.beginPath() - roundRect(ctx, W - 155, 12, 143, 38, 8) - ctx.fill() - ctx.fillStyle = '#fff' - ctx.font = '13px system-ui' - ctx.textAlign = 'right' - ctx.fillText(`Pen shrinks in ${Math.ceil(shrinkIn)}s`, W - 18, 36) - } - - // level title - ctx.fillStyle = 'rgba(255,255,255,0.6)' - ctx.font = '11px system-ui' - ctx.textAlign = 'left' - ctx.fillText(`Level ${cfg.id}: ${cfg.title}`, 12, H - 10) - - // menu button (bottom-right) - const mb = this.menuButtonRect(W, H) - ctx.fillStyle = 'rgba(0,0,0,0.45)' - ctx.beginPath() - roundRect(ctx, mb.x, mb.y, mb.w, mb.h, 8) - ctx.fill() - ctx.fillStyle = '#fff' - ctx.font = '13px system-ui' - ctx.textAlign = 'center' - ctx.fillText('☰ Menu', mb.x + mb.w / 2, mb.y + mb.h / 2 + 5) - } - - private drawEndScreen(ctx: CanvasRenderingContext2D, W: number, H: number) { - ctx.fillStyle = 'rgba(0,0,0,0.55)' - ctx.fillRect(0, 0, W, H) - - const win = this.phase === 'WIN' - const title = win ? 'Level Complete!' : 'Time\'s Up!' - const sub = win - ? (this.levelIndex + 1 < LEVELS.length ? 'Tap to continue →' : 'You finished the game!') - : 'Tap to try again' - - ctx.fillStyle = win ? '#7eff7e' : '#ff7e7e' - ctx.font = `bold ${Math.round(W * 0.07)}px system-ui` - ctx.textAlign = 'center' - ctx.fillText(title, W / 2, H / 2 - 20) - - ctx.fillStyle = '#fff' - ctx.font = `${Math.round(W * 0.035)}px system-ui` - ctx.fillText(sub, W / 2, H / 2 + 30) - - if (win && this.config.subtitle) { - ctx.fillStyle = 'rgba(255,255,255,0.6)' - ctx.font = `italic ${Math.round(W * 0.022)}px system-ui` - ctx.fillText(this.config.subtitle, W / 2, H / 2 + 65) - } - } - - private drawLevelSelect(ctx: CanvasRenderingContext2D, W: number, H: number) { - ctx.fillStyle = '#fff' - ctx.font = `bold ${Math.round(W * 0.06)}px system-ui` - ctx.textAlign = 'center' - ctx.fillText('Herd', W / 2, H * 0.22) - - ctx.fillStyle = 'rgba(255,255,255,0.75)' - ctx.font = `${Math.round(W * 0.028)}px system-ui` - ctx.fillText('Tap a level to begin', W / 2, H * 0.31) - - const cols = 5, rows = 2 - const cellW = Math.min(160, W / (cols + 0.5)) - const cellH = 88 - const startX = W / 2 - (cols * cellW * 1.2) / 2 - const startY = H * 0.38 - - LEVELS.forEach((level, i) => { - const col = i % cols - const row = Math.floor(i / cols) - const cx = startX + col * cellW * 1.2 + cellW / 2 - const cy = startY + row * (cellH + 20) - const locked = i > this.unlockedUpTo - - ctx.fillStyle = locked ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.15)' - ctx.beginPath() - roundRect(ctx, cx - cellW / 2, cy - cellH / 2, cellW, cellH, 10) - ctx.fill() - - if (locked) { - ctx.fillStyle = 'rgba(255,255,255,0.2)' - ctx.font = `${Math.round(cellW * 0.30)}px system-ui` - ctx.textAlign = 'center' - ctx.fillText('🔒', cx, cy + 8) - } else { - ctx.fillStyle = '#fff' - ctx.font = `bold ${Math.round(cellW * 0.22)}px system-ui` - ctx.textAlign = 'center' - ctx.fillText(`${level.id}`, cx, cy + 5) - } - - ctx.fillStyle = locked ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.6)' - ctx.font = `${Math.round(cellW * 0.11)}px system-ui` - ctx.fillText(level.title, cx, cy + cellH / 2 - 6) - }) - - void rows - - // footer - ctx.font = '11px system-ui' - ctx.textAlign = 'center' - - // repo link - ctx.fillStyle = 'rgba(255,255,255,0.5)' - ctx.fillText('gitlab.com/PavelSafronov/herd', W / 2, H - 30) - - // attribution - ctx.fillStyle = 'rgba(255,255,255,0.35)' - ctx.fillText('Icon by Icongeek26 via Flaticon', W / 2, H - 12) - } - - handleTap(x: number, y: number) { - if (this.phase === 'LEVEL_SELECT') { - const W = this.canvas.width - const H = this.canvas.height - - // footer link hit-test (bottom ~48px of screen) - if (y > H - 48) { - if (y > H - 24) { - window.open('https://www.flaticon.com/authors/icongeek26/outline?author_id=296&type=standard', '_blank') - } else { - window.open('https://gitlab.com/PavelSafronov/herd', '_blank') - } - return - } - - // hit test level buttons - const cols = 5 - const cellW = Math.min(160, W / (cols + 0.5)) - const cellH = 88 - const startX = W / 2 - (cols * cellW * 1.2) / 2 - const startY = H * 0.38 - - for (let i = 0; i < LEVELS.length; i++) { - const col = i % cols - const row = Math.floor(i / cols) - const cx = startX + col * cellW * 1.2 + cellW / 2 - const cy = startY + row * (cellH + 20) - if (Math.abs(x - cx) < cellW / 2 && Math.abs(y - cy) < cellH / 2) { - if (i <= this.unlockedUpTo) this.startLevel(i) - return - } - } - return - } - - if (this.phase === 'WIN') { - if (this.endMessageTimer < 0.5) return // debounce - if (this.levelIndex + 1 < LEVELS.length) { - this.startLevel(this.levelIndex + 1) - } else { - this.phase = 'LEVEL_SELECT' - } - return - } - - if (this.phase === 'FAIL') { - if (this.endMessageTimer < 0.5) return - this.startLevel(this.levelIndex) - return - } - - // PLAYING — check menu button first, then forward to dog - const { width: W2, height: H2 } = this.canvas - const mb2 = this.menuButtonRect(W2, H2) - if (x >= mb2.x && x <= mb2.x + mb2.w && y >= mb2.y && y <= mb2.y + mb2.h) { - this.phase = 'LEVEL_SELECT' - return - } - this.dog.setTarget(x, y) - } -} - - -function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { - ctx.beginPath() - ctx.moveTo(x + r, y) - ctx.lineTo(x + w - r, y) - ctx.arcTo(x + w, y, x + w, y + r, r) - ctx.lineTo(x + w, y + h - r) - ctx.arcTo(x + w, y + h, x + w - r, y + h, r) - ctx.lineTo(x + r, y + h) - ctx.arcTo(x, y + h, x, y + h - r, r) - ctx.lineTo(x, y + r) - ctx.arcTo(x, y, x + r, y, r) - ctx.closePath() } diff --git a/src/game/UI.ts b/src/game/UI.ts new file mode 100644 index 0000000..d165cf6 --- /dev/null +++ b/src/game/UI.ts @@ -0,0 +1,188 @@ +import type { GameState } from './GameState.ts' +import { LEVELS } from '../levels/LevelConfig.ts' + +export function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} + +export class UI { + private state: GameState + + private screenLevelSelect: HTMLElement + private levelGrid: HTMLElement + private hud: HTMLElement + private hudCounter: HTMLElement + private hudTimer: HTMLElement + private timerFill: HTMLElement + private timerText: HTMLElement + private hudShrink: HTMLElement + private hudLevelName: HTMLElement + private screenEnd: HTMLElement + private endTitle: HTMLElement + private endSub: HTMLElement + private endTime: HTMLElement + private endRecord: HTMLElement + private endSubtitle: HTMLElement + + private levelBtns: HTMLButtonElement[] = [] + + constructor(state: GameState, canvasEl: HTMLCanvasElement) { + this.state = state + + this.screenLevelSelect = this.el('#screen-level-select') + this.levelGrid = this.el('#level-grid') + this.hud = this.el('#hud') + this.hudCounter = this.el('#hud-counter') + this.hudTimer = this.el('#hud-timer') + this.timerFill = this.el('#timer-fill') + this.timerText = this.el('#timer-text') + this.hudShrink = this.el('#hud-shrink') + this.hudLevelName = this.el('#hud-level-name') + this.screenEnd = this.el('#screen-end') + this.endTitle = this.el('#end-title') + this.endSub = this.el('#end-sub') + this.endTime = this.el('#end-time') + this.endRecord = this.el('#end-record') + this.endSubtitle = this.el('#end-subtitle') + + this.buildLevelGrid() + this.wireEvents(canvasEl) + } + + private el(selector: string): HTMLElement { + return document.querySelector(selector) as HTMLElement + } + + private buildLevelGrid() { + this.levelGrid.innerHTML = '' + this.levelBtns = [] + LEVELS.forEach((level, i) => { + const btn = document.createElement('button') + btn.className = 'level-btn locked' + btn.innerHTML = ` + <span class="lv-lock">🔒</span> + <span class="lv-num">${level.id}</span> + <span class="lv-name">${level.title}</span> + <span class="lv-best"></span> + ` + btn.addEventListener('click', () => { + if (i <= this.state.unlockedUpTo) this.state.startLevel(i) + }) + this.levelGrid.appendChild(btn) + this.levelBtns.push(btn) + }) + } + + private wireEvents(canvasEl: HTMLCanvasElement) { + this.el('#btn-menu').addEventListener('click', () => this.state.goToMenu()) + + this.screenEnd.addEventListener('click', () => this.state.advance()) + + canvasEl.addEventListener('pointerdown', (e: PointerEvent) => { + if (this.state.phase !== 'PLAYING') return + const rect = canvasEl.getBoundingClientRect() + this.state.setDogTarget(e.clientX - rect.left, e.clientY - rect.top) + }) + + window.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') this.state.goToMenu() + }) + } + + update() { + const { phase } = this.state + const onGame = phase === 'PLAYING' || phase === 'WIN' || phase === 'FAIL' + + this.screenLevelSelect.classList.toggle('hidden', phase !== 'LEVEL_SELECT') + this.hud.classList.toggle('hidden', !onGame) + this.screenEnd.classList.toggle('hidden', phase !== 'WIN' && phase !== 'FAIL') + + if (phase === 'LEVEL_SELECT') this.updateLevelGrid() + if (onGame) this.updateHUD() + if (phase === 'WIN' || phase === 'FAIL') this.updateEndScreen() + } + + private updateLevelGrid() { + LEVELS.forEach((_, i) => { + const btn = this.levelBtns[i]! + const locked = i > this.state.unlockedUpTo + const best = this.state.bestTimes[i] + + btn.classList.toggle('locked', locked) + + const lockEl = btn.querySelector('.lv-lock') as HTMLElement + const numEl = btn.querySelector('.lv-num') as HTMLElement + lockEl.classList.toggle('hidden', !locked) + numEl.classList.toggle('hidden', locked) + + const bestEl = btn.querySelector('.lv-best') as HTMLElement + bestEl.textContent = best !== null ? formatTime(best) : '' + }) + } + + private updateHUD() { + const { config: cfg, inPen, sheepCount, elapsed, lastShrinkAt } = this.state + if (!cfg) return + + const hasSlack = cfg.winThreshold < sheepCount + this.hudCounter.innerHTML = `🐑 ${inPen} / ${cfg.winThreshold}${ + hasSlack ? `<small>(${sheepCount} total)</small>` : '' + }` + + if (cfg.timeLimitSeconds !== null) { + this.hudTimer.classList.remove('hidden') + const remaining = Math.max(0, cfg.timeLimitSeconds - elapsed) + const fraction = remaining / cfg.timeLimitSeconds + const color = fraction > 0.4 ? '#5dde5d' : fraction > 0.2 ? '#f0a020' : '#e03030' + this.timerFill.style.width = `${fraction * 100}%` + this.timerFill.style.backgroundColor = color + this.timerText.textContent = `${Math.ceil(remaining)}s` + } else { + this.hudTimer.classList.add('hidden') + } + + if (cfg.penShrinksEverySeconds !== null) { + this.hudShrink.classList.remove('hidden') + const nextShrink = (lastShrinkAt + 1) * cfg.penShrinksEverySeconds + const shrinkIn = Math.max(0, nextShrink - elapsed) + this.hudShrink.textContent = `Pen shrinks in ${Math.ceil(shrinkIn)}s` + this.hudShrink.classList.toggle('urgent', shrinkIn < 8) + } else { + this.hudShrink.classList.add('hidden') + } + + this.hudLevelName.textContent = `Level ${cfg.id}: ${cfg.title}` + } + + private updateEndScreen() { + const { phase, levelIndex, elapsed, bestTimes, isNewRecord, config: cfg } = this.state + const win = phase === 'WIN' + + this.endTitle.textContent = win ? 'Level Complete!' : "Time's Up!" + this.endTitle.style.color = win ? '#7eff7e' : '#ff7e7e' + this.endSub.textContent = win + ? (levelIndex + 1 < LEVELS.length ? 'Tap to continue →' : 'You finished the game!') + : 'Tap to try again' + + if (win) { + const best = bestTimes[levelIndex] + const timeStr = formatTime(elapsed) + const bestStr = best !== null ? formatTime(best) : null + this.endTime.textContent = `Time: ${timeStr}${bestStr ? ` • Best: ${bestStr}` : ''}` + this.endTime.classList.remove('hidden') + } else { + this.endTime.classList.add('hidden') + } + + this.endRecord.classList.toggle('hidden', !(win && isNewRecord)) + + if (win && cfg?.subtitle) { + this.endSubtitle.textContent = cfg.subtitle + this.endSubtitle.classList.remove('hidden') + } else { + this.endSubtitle.classList.add('hidden') + } + } +} diff --git a/src/main.ts b/src/main.ts index 8b6f1b4..789b82d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,21 +1,15 @@ import { Canvas } from './game/Canvas.ts' import { GameLoop } from './game/GameLoop.ts' import { GameState } from './game/GameState.ts' +import { UI } from './game/UI.ts' const canvas = new Canvas('game') const state = new GameState(canvas) +const ui = new UI(state, canvas.el) + const loop = new GameLoop( (dt) => state.update(dt), - (_alpha) => { canvas.clear(); state.render(_alpha) }, + (alpha) => { canvas.clear(); state.render(alpha); ui.update() }, ) -canvas.el.addEventListener('pointerdown', (e: PointerEvent) => { - const rect = canvas.el.getBoundingClientRect() - state.handleTap(e.clientX - rect.left, e.clientY - rect.top) -}) - -window.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') state.goToMenu() -}) - loop.start()