herd / files / src / game / GameState.ts
GameState.ts
| 1 | import { Sheep } from '../entities/Sheep.ts' |
| 2 | import { Dog } from '../entities/Dog.ts' |
| 3 | import { Pen } from '../entities/Pen.ts' |
| 4 | import { updateBoids } from '../ai/Boids.ts' |
| 5 | import { LEVELS, type LevelConfig } from '../levels/LevelConfig.ts' |
| 6 | import type { Canvas } from './Canvas.ts' |
| 7 | |
| 8 | export type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL' |
| 9 | |
| 10 | const PEN_SHRINK_RATE = 0.1 |
| 11 | const MIN_PEN_FRACTION = 0.05 |
| 12 | |
| 13 | export class GameState { |
| 14 | phase: Phase = 'LEVEL_SELECT' |
| 15 | levelIndex = 0 |
| 16 | config!: LevelConfig |
| 17 | |
| 18 | private sheep: Sheep[] = [] |
| 19 | private dog!: Dog |
| 20 | private pen!: Pen |
| 21 | private obstacles: Array<{ x: number; y: number; r: number }> = [] |
| 22 | |
| 23 | scale = 1 |
| 24 | elapsed = 0 |
| 25 | endMessageTimer = 0 |
| 26 | |
| 27 | lastShrinkAt = 0 |
| 28 | private penW0 = 0 |
| 29 | private penH0 = 0 |
| 30 | |
| 31 | unlockedUpTo = 0 |
| 32 | bestTimes: (number | null)[] = Array(LEVELS.length).fill(null) |
| 33 | isNewRecord = false |
| 34 | inPen = 0 // sheep count inside pen, cached every update() tick |
| 35 | |
| 36 | private canvas: Canvas |
| 37 | |
| 38 | constructor(canvas: Canvas) { |
| 39 | this.canvas = canvas |
| 40 | const savedProgress = localStorage.getItem('herd_progress') |
| 41 | if (savedProgress !== null) this.unlockedUpTo = parseInt(savedProgress, 10) |
| 42 | const savedTimes = localStorage.getItem('herd_times') |
| 43 | if (savedTimes !== null) this.bestTimes = JSON.parse(savedTimes) as (number | null)[] |
| 44 | } |
| 45 | |
| 46 | get sheepCount(): number { return this.sheep.length } |
| 47 | |
| 48 | goToMenu() { |
| 49 | if (this.phase !== 'LEVEL_SELECT') this.phase = 'LEVEL_SELECT' |
| 50 | } |
| 51 | |
| 52 | // Advances past WIN/FAIL screen (0.5 s debounce to prevent accidental double-tap). |
| 53 | advance() { |
| 54 | if (this.phase === 'WIN') { |
| 55 | if (this.endMessageTimer < 0.5) return |
| 56 | if (this.levelIndex + 1 < LEVELS.length) { |
| 57 | this.startLevel(this.levelIndex + 1) |
| 58 | } else { |
| 59 | this.phase = 'LEVEL_SELECT' |
| 60 | } |
| 61 | } else if (this.phase === 'FAIL') { |
| 62 | if (this.endMessageTimer < 0.5) return |
| 63 | this.startLevel(this.levelIndex) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | setDogTarget(x: number, y: number) { |
| 68 | this.dog.setTarget(x, y) |
| 69 | } |
| 70 | |
| 71 | startLevel(index: number) { |
| 72 | this.levelIndex = index |
| 73 | this.config = LEVELS[index]! |
| 74 | const cfg = this.config |
| 75 | const { width: W, height: H } = this.canvas |
| 76 | |
| 77 | this.scale = Math.min(W, H) / 600 |
| 78 | |
| 79 | const pen = cfg.penRect |
| 80 | this.pen = new Pen( |
| 81 | pen.x * W, pen.y * H, |
| 82 | pen.w * W, pen.h * H, |
| 83 | cfg.penGateSide, |
| 84 | ) |
| 85 | this.penW0 = this.pen.w |
| 86 | this.penH0 = this.pen.h |
| 87 | |
| 88 | const minDim = Math.min(W, H) |
| 89 | this.obstacles = cfg.obstacles.map(o => ({ |
| 90 | x: o.x * W, y: o.y * H, r: o.r * minDim, |
| 91 | })) |
| 92 | |
| 93 | this.sheep = [] |
| 94 | const overrideMap = new Map(cfg.sheepOverrides.map(o => [o.index, o])) |
| 95 | let sheepIdx = 0 |
| 96 | |
| 97 | for (const cluster of cfg.spawnClusters) { |
| 98 | for (let i = 0; i < cluster.count; i++) { |
| 99 | const angle = Math.random() * Math.PI * 2 |
| 100 | const dist = Math.random() * cluster.r * minDim |
| 101 | const x = cluster.cx * W + Math.cos(angle) * dist |
| 102 | const y = cluster.cy * H + Math.sin(angle) * dist |
| 103 | const override = overrideMap.get(sheepIdx) ?? {} |
| 104 | this.sheep.push(new Sheep(x, y, override)) |
| 105 | sheepIdx++ |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale) |
| 110 | |
| 111 | this.elapsed = 0 |
| 112 | this.lastShrinkAt = 0 |
| 113 | this.endMessageTimer = 0 |
| 114 | this.isNewRecord = false |
| 115 | this.inPen = 0 |
| 116 | this.phase = 'PLAYING' |
| 117 | } |
| 118 | |
| 119 | update(dt: number) { |
| 120 | if (this.phase !== 'PLAYING') { |
| 121 | if (this.phase === 'WIN' || this.phase === 'FAIL') { |
| 122 | this.endMessageTimer += dt |
| 123 | } |
| 124 | return |
| 125 | } |
| 126 | |
| 127 | this.elapsed += dt |
| 128 | const cfg = this.config |
| 129 | const { width: W, height: H } = this.canvas |
| 130 | |
| 131 | const minDim = Math.min(W, H) |
| 132 | for (const dobs of cfg.dynamicObstacles) { |
| 133 | if (this.elapsed >= dobs.appearsAtSeconds) { |
| 134 | if (!this.obstacles.some(o => Math.abs(o.x - dobs.x * W) < 1)) { |
| 135 | this.obstacles.push({ x: dobs.x * W, y: dobs.y * H, r: dobs.r * minDim }) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | if (cfg.penShrinksEverySeconds !== null) { |
| 141 | const intervals = Math.floor(this.elapsed / cfg.penShrinksEverySeconds) |
| 142 | if (intervals > this.lastShrinkAt) { |
| 143 | const shrinks = intervals - this.lastShrinkAt |
| 144 | this.lastShrinkAt = intervals |
| 145 | const factor = Math.pow(1 - PEN_SHRINK_RATE, shrinks) |
| 146 | const newW = Math.max(this.penW0 * MIN_PEN_FRACTION, this.pen.w * factor) |
| 147 | const newH = Math.max(this.penH0 * MIN_PEN_FRACTION, this.pen.h * factor) |
| 148 | this.pen.x += (this.pen.w - newW) / 2 |
| 149 | this.pen.y += (this.pen.h - newH) / 2 |
| 150 | this.pen.w = newW |
| 151 | this.pen.h = newH |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | this.dog.update(dt) |
| 156 | |
| 157 | const speedMult = cfg.sheepSpeedMult ?? 1 |
| 158 | updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale) |
| 159 | |
| 160 | this.inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length |
| 161 | |
| 162 | if (this.inPen >= cfg.winThreshold) { |
| 163 | this.phase = 'WIN' |
| 164 | if (this.levelIndex + 1 > this.unlockedUpTo) { |
| 165 | this.unlockedUpTo = this.levelIndex + 1 |
| 166 | localStorage.setItem('herd_progress', String(this.unlockedUpTo)) |
| 167 | } |
| 168 | const prev = this.bestTimes[this.levelIndex] |
| 169 | if (prev === null || this.elapsed < prev) { |
| 170 | this.bestTimes[this.levelIndex] = this.elapsed |
| 171 | this.isNewRecord = true |
| 172 | localStorage.setItem('herd_times', JSON.stringify(this.bestTimes)) |
| 173 | } |
| 174 | return |
| 175 | } |
| 176 | |
| 177 | if (cfg.timeLimitSeconds !== null && this.elapsed >= cfg.timeLimitSeconds) { |
| 178 | this.phase = 'FAIL' |
| 179 | } |
| 180 | |
| 181 | if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) { |
| 182 | this.phase = 'FAIL' |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | render(_alpha: number) { |
| 187 | const ctx = this.canvas.ctx |
| 188 | const { width: W, height: H } = this.canvas |
| 189 | |
| 190 | this.canvas.drawField() |
| 191 | |
| 192 | if (this.phase === 'LEVEL_SELECT') return |
| 193 | |
| 194 | for (const obs of this.obstacles) { |
| 195 | ctx.fillStyle = '#8b7355' |
| 196 | ctx.beginPath() |
| 197 | ctx.arc(obs.x, obs.y, obs.r, 0, Math.PI * 2) |
| 198 | ctx.fill() |
| 199 | ctx.strokeStyle = '#6b5335' |
| 200 | ctx.lineWidth = 2 |
| 201 | ctx.stroke() |
| 202 | } |
| 203 | |
| 204 | this.pen.draw(ctx, Math.min(1, this.inPen / this.config.winThreshold), this.scale) |
| 205 | |
| 206 | if (this.config.darkMode) { |
| 207 | this.applyDarkMode(ctx, W, H) |
| 208 | } |
| 209 | |
| 210 | for (const s of this.sheep) { |
| 211 | if (this.config.darkMode) { |
| 212 | const dSq = s.pos.distanceSqTo(this.dog.pos) |
| 213 | if (dSq > (120 * this.scale) ** 2) continue |
| 214 | } |
| 215 | s.draw(ctx, this.scale) |
| 216 | } |
| 217 | |
| 218 | this.dog.draw(ctx, this.scale) |
| 219 | } |
| 220 | |
| 221 | private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) { |
| 222 | const { x, y } = this.dog.pos |
| 223 | const torchR = 120 * this.scale |
| 224 | const gradient = ctx.createRadialGradient(x, y, 0, x, y, torchR) |
| 225 | gradient.addColorStop(0, 'rgba(0,0,0,0)') |
| 226 | gradient.addColorStop(0.6, 'rgba(0,0,0,0.55)') |
| 227 | gradient.addColorStop(1, 'rgba(0,0,0,0.92)') |
| 228 | |
| 229 | ctx.save() |
| 230 | ctx.globalCompositeOperation = 'source-atop' |
| 231 | |
| 232 | ctx.fillStyle = 'rgba(0,0,0,0.88)' |
| 233 | ctx.fillRect(0, 0, W, H) |
| 234 | |
| 235 | ctx.globalCompositeOperation = 'destination-out' |
| 236 | ctx.fillStyle = gradient |
| 237 | ctx.beginPath() |
| 238 | ctx.arc(x, y, torchR, 0, Math.PI * 2) |
| 239 | ctx.fill() |
| 240 | |
| 241 | ctx.restore() |
| 242 | } |
| 243 | } |