fuzzyworld

herd / commit

Add game state machine, input handler, and entry point

2eda845edff2eb8a8076f1ec73cd97f49676fdfb · Pavel Safronov · 2026-05-16 22:04:44 -0700 · parents 4e15f13cfbed

Add game state machine, input handler, and entry point

State machine covers LEVEL_SELECT → PLAYING → WIN | FAIL. HUD shows sheep
counter, timer bar, and pen-shrink warning. Menu button (bottom-right) and
Escape key return to level select from any in-game state. Level select
footer links to the repo and icon attribution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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()