herd / files / src / game / GameState.ts
GameState.ts
2eda845e 2026-05-17 Pavel Safronov | 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 | ||
82e8c064 2026-05-18 Pavel Safronov | 8 | export type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL' |
2eda845e 2026-05-17 Pavel Safronov | 9 | |
82e8c064 2026-05-18 Pavel Safronov | 10 | const PEN_SHRINK_RATE = 0.1 |
2eda845e 2026-05-17 Pavel Safronov | 11 | const MIN_PEN_FRACTION = 0.05 |
| 12 | ||
| 13 | export class GameState { | |
82e8c064 2026-05-18 Pavel Safronov | 14 | phase: Phase = 'LEVEL_SELECT' |
| 15 | levelIndex = 0 | |
| 16 | config!: LevelConfig | |
2eda845e 2026-05-17 Pavel Safronov | 17 | |
| 18 | private sheep: Sheep[] = [] | |
| 19 | private dog!: Dog | |
| 20 | private pen!: Pen | |
| 21 | private obstacles: Array<{ x: number; y: number; r: number }> = [] | |
| 22 | ||
82e8c064 2026-05-18 Pavel Safronov | 23 | scale = 1 |
| 24 | elapsed = 0 | |
| 25 | endMessageTimer = 0 | |
2eda845e 2026-05-17 Pavel Safronov | 26 | |
82e8c064 2026-05-18 Pavel Safronov | 27 | lastShrinkAt = 0 |
2eda845e 2026-05-17 Pavel Safronov | 28 | private penW0 = 0 |
| 29 | private penH0 = 0 | |
| 30 | ||
82e8c064 2026-05-18 Pavel Safronov | 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 | |
68f7d108 2026-05-18 Pavel Safronov | 35 | |
2eda845e 2026-05-17 Pavel Safronov | 36 | private canvas: Canvas |
82e8c064 2026-05-18 Pavel Safronov | 37 | |
68f7d108 2026-05-18 Pavel Safronov | 38 | constructor(canvas: Canvas) { |
| 39 | this.canvas = canvas | |
82e8c064 2026-05-18 Pavel Safronov | 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)[] | |
68f7d108 2026-05-18 Pavel Safronov | 44 | } |
2eda845e 2026-05-17 Pavel Safronov | 45 | |
82e8c064 2026-05-18 Pavel Safronov | 46 | get sheepCount(): number { return this.sheep.length } |
2eda845e 2026-05-17 Pavel Safronov | 47 | |
| 48 | goToMenu() { | |
| 49 | if (this.phase !== 'LEVEL_SELECT') this.phase = 'LEVEL_SELECT' | |
| 50 | } | |
| 51 | ||
82e8c064 2026-05-18 Pavel Safronov | 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 | ||
2eda845e 2026-05-17 Pavel Safronov | 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 | ||
21437a01 2026-05-18 Pavel Safronov | 77 | this.scale = Math.min(W, H) / 600 |
| 78 | ||
2eda845e 2026-05-17 Pavel Safronov | 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 | ||
21437a01 2026-05-18 Pavel Safronov | 109 | this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale) |
2eda845e 2026-05-17 Pavel Safronov | 110 | |
| 111 | this.elapsed = 0 | |
| 112 | this.lastShrinkAt = 0 | |
| 113 | this.endMessageTimer = 0 | |
82e8c064 2026-05-18 Pavel Safronov | 114 | this.isNewRecord = false |
| 115 | this.inPen = 0 | |
2eda845e 2026-05-17 Pavel Safronov | 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 | |
21437a01 2026-05-18 Pavel Safronov | 158 | updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale) |
2eda845e 2026-05-17 Pavel Safronov | 159 | |
82e8c064 2026-05-18 Pavel Safronov | 160 | this.inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length |
2eda845e 2026-05-17 Pavel Safronov | 161 | |
82e8c064 2026-05-18 Pavel Safronov | 162 | if (this.inPen >= cfg.winThreshold) { |
2eda845e 2026-05-17 Pavel Safronov | 163 | this.phase = 'WIN' |
68f7d108 2026-05-18 Pavel Safronov | 164 | if (this.levelIndex + 1 > this.unlockedUpTo) { |
| 165 | this.unlockedUpTo = this.levelIndex + 1 | |
| 166 | localStorage.setItem('herd_progress', String(this.unlockedUpTo)) | |
| 167 | } | |
82e8c064 2026-05-18 Pavel Safronov | 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 | } | |
2eda845e 2026-05-17 Pavel Safronov | 174 | return |
| 175 | } | |
| 176 | ||
| 177 | if (cfg.timeLimitSeconds !== null && this.elapsed >= cfg.timeLimitSeconds) { | |
| 178 | this.phase = 'FAIL' | |
| 179 | } | |
| 180 | ||
21437a01 2026-05-18 Pavel Safronov | 181 | if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) { |
2eda845e 2026-05-17 Pavel Safronov | 182 | this.phase = 'FAIL' |
| 183 | } | |
| 184 | } | |
| 185 | ||
82e8c064 2026-05-18 Pavel Safronov | 186 | render(_alpha: number) { |
2eda845e 2026-05-17 Pavel Safronov | 187 | const ctx = this.canvas.ctx |
| 188 | const { width: W, height: H } = this.canvas | |
| 189 | ||
| 190 | this.canvas.drawField() | |
| 191 | ||
82e8c064 2026-05-18 Pavel Safronov | 192 | if (this.phase === 'LEVEL_SELECT') return |
2eda845e 2026-05-17 Pavel Safronov | 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 | ||
82e8c064 2026-05-18 Pavel Safronov | 204 | this.pen.draw(ctx, Math.min(1, this.inPen / this.config.winThreshold), this.scale) |
2eda845e 2026-05-17 Pavel Safronov | 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) | |
82e8c064 2026-05-18 Pavel Safronov | 213 | if (dSq > (120 * this.scale) ** 2) continue |
2eda845e 2026-05-17 Pavel Safronov | 214 | } |
21437a01 2026-05-18 Pavel Safronov | 215 | s.draw(ctx, this.scale) |
2eda845e 2026-05-17 Pavel Safronov | 216 | } |
| 217 | ||
21437a01 2026-05-18 Pavel Safronov | 218 | this.dog.draw(ctx, this.scale) |
2eda845e 2026-05-17 Pavel Safronov | 219 | } |
| 220 | ||
| 221 | private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) { | |
| 222 | const { x, y } = this.dog.pos | |
21437a01 2026-05-18 Pavel Safronov | 223 | const torchR = 120 * this.scale |
| 224 | const gradient = ctx.createRadialGradient(x, y, 0, x, y, torchR) | |
2eda845e 2026-05-17 Pavel Safronov | 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() | |
21437a01 2026-05-18 Pavel Safronov | 238 | ctx.arc(x, y, torchR, 0, Math.PI * 2) |
2eda845e 2026-05-17 Pavel Safronov | 239 | ctx.fill() |
| 240 | ||
| 241 | ctx.restore() | |
| 242 | } | |
| 243 | } |