herd / commit
Add game state machine, input handler, and entry point
src/game/GameState.ts | 466 ++++++++++++++++++++++++++++++++++++++++++++++ src/input/InputHandler.ts | 13 ++ src/main.ts | 21 +++ 3 files changed, 500 insertions(+) diff --git a/src/game/GameState.ts b/src/game/GameState.ts new file mode 100644 index 0000000..ed31d5b --- /dev/null +++ b/src/game/GameState.ts @@ -0,0 +1,466 @@ +import { Sheep } from '../entities/Sheep.ts' +import { Dog } from '../entities/Dog.ts' +import { Pen } from '../entities/Pen.ts' +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' + +const PEN_SHRINK_RATE = 0.1 // 10% per interval +const MIN_PEN_FRACTION = 0.05 + +export class GameState { + private phase: Phase = 'LEVEL_SELECT' + private levelIndex = 0 + private config!: LevelConfig + + private sheep: Sheep[] = [] + private dog!: Dog + private pen!: Pen + private obstacles: Array<{ x: number; y: number; r: number }> = [] + + private elapsed = 0 // seconds since level start + private penFraction = 0 // fraction of sheep inside pen + private endMessageTimer = 0 + + // pen shrink tracking + private lastShrinkAt = 0 + private penW0 = 0 + private penH0 = 0 + + private canvas: Canvas + constructor(canvas: Canvas) { this.canvas = canvas } + + get currentDog() { return this.dog } + + goToMenu() { + if (this.phase !== 'LEVEL_SELECT') this.phase = 'LEVEL_SELECT' + } + + startLevel(index: number) { + this.levelIndex = index + this.config = LEVELS[index]! + const cfg = this.config + const { width: W, height: H } = this.canvas + + // resolve pen rect from fractions + const pen = cfg.penRect + this.pen = new Pen( + pen.x * W, pen.y * H, + pen.w * W, pen.h * H, + cfg.penGateSide, + ) + 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 + + for (const cluster of cfg.spawnClusters) { + for (let i = 0; i < cluster.count; i++) { + const angle = Math.random() * Math.PI * 2 + const dist = Math.random() * cluster.r * minDim + const x = cluster.cx * W + Math.cos(angle) * dist + const y = cluster.cy * H + Math.sin(angle) * dist + const override = overrideMap.get(sheepIdx) ?? {} + this.sheep.push(new Sheep(x, y, override)) + sheepIdx++ + } + } + + // place dog at a neutral position away from the pen + this.dog = new Dog(W * 0.5, H * 0.75, cfg.dogSpeed ?? 160) + if (cfg.sheepSpeedMult) { + // speed mult is applied in boids via a per-level constant injected below + } + + this.elapsed = 0 + this.lastShrinkAt = 0 + this.penFraction = 0 + this.endMessageTimer = 0 + this.phase = 'PLAYING' + } + + update(dt: number) { + if (this.phase !== 'PLAYING') { + if (this.phase === 'WIN' || this.phase === 'FAIL') { + this.endMessageTimer += dt + } + return + } + + this.elapsed += dt + 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) { + const shrinks = intervals - this.lastShrinkAt + this.lastShrinkAt = intervals + 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 + this.pen.h = newH + } + } + + // update entities + this.dog.update(dt) + + const speedMult = cfg.sheepSpeedMult ?? 1 + updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult) + + // count sheep in pen + const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length + this.penFraction = inPen / this.sheep.length + + // check win + if (this.penFraction >= cfg.winThreshold) { + this.phase = 'WIN' + 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 * 30) { + this.phase = 'FAIL' + } + } + + 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 + } + + // obstacles + for (const obs of this.obstacles) { + ctx.fillStyle = '#8b7355' + ctx.beginPath() + ctx.arc(obs.x, obs.y, obs.r, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = '#6b5335' + ctx.lineWidth = 2 + ctx.stroke() + } + + // pen + const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length + this.pen.draw(ctx, inPen / this.sheep.length) + + // 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 * 120) continue // invisible outside torch radius + } + s.draw(ctx) + } + + // dog + this.dog.draw(ctx) + + // 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) { + const { x, y } = this.dog.pos + const gradient = ctx.createRadialGradient(x, y, 0, x, y, 120) + gradient.addColorStop(0, 'rgba(0,0,0,0)') + gradient.addColorStop(0.6, 'rgba(0,0,0,0.55)') + gradient.addColorStop(1, 'rgba(0,0,0,0.92)') + + 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() + ctx.arc(x, y, 120, 0, Math.PI * 2) + ctx.fill() + + 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 + ctx.fillStyle = 'rgba(0,0,0,0.45)' + ctx.beginPath() + roundRect(ctx, 12, 12, 130, 38, 8) + ctx.fill() + + ctx.fillStyle = '#fff' + ctx.font = 'bold 16px system-ui' + ctx.textAlign = 'left' + ctx.fillText(`🐑 ${inPen} / ${this.sheep.length}`, 20, 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(90, W / (cols + 1)) + const cellH = 64 + const startX = W / 2 - (cols * cellW * 1.2) / 2 + const startY = H * 0.42 + + 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) + + ctx.fillStyle = 'rgba(255,255,255,0.15)' + ctx.beginPath() + roundRect(ctx, cx - cellW / 2, cy - cellH / 2, cellW, cellH, 10) + ctx.fill() + + ctx.fillStyle = '#fff' + ctx.font = `bold ${Math.round(cellW * 0.28)}px system-ui` + ctx.textAlign = 'center' + ctx.fillText(`${level.id}`, cx, cy + 5) + + ctx.fillStyle = 'rgba(255,255,255,0.6)' + ctx.font = `${Math.round(cellW * 0.15)}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(90, W / (cols + 1)) + const cellH = 64 + const startX = W / 2 - (cols * cellW * 1.2) / 2 + const startY = H * 0.42 + + 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) { + 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/input/InputHandler.ts b/src/input/InputHandler.ts new file mode 100644 index 0000000..3e79d51 --- /dev/null +++ b/src/input/InputHandler.ts @@ -0,0 +1,13 @@ +import type { Canvas } from '../game/Canvas.ts' +import type { Dog } from '../entities/Dog.ts' + +export class InputHandler { + constructor(canvas: Canvas, dog: Dog) { + canvas.el.addEventListener('pointerdown', (e: PointerEvent) => { + const rect = canvas.el.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + dog.setTarget(x, y) + }) + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8b6f1b4 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,21 @@ +import { Canvas } from './game/Canvas.ts' +import { GameLoop } from './game/GameLoop.ts' +import { GameState } from './game/GameState.ts' + +const canvas = new Canvas('game') +const state = new GameState(canvas) +const loop = new GameLoop( + (dt) => state.update(dt), + (_alpha) => { canvas.clear(); state.render(_alpha) }, +) + +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()