fuzzyworld

herd / files / src / game / GameState.ts

GameState.ts

7.0 KB · history · blame

1import { Sheep } from '../entities/Sheep.ts'
2import { Dog } from '../entities/Dog.ts'
3import { Pen } from '../entities/Pen.ts'
4import { updateBoids } from '../ai/Boids.ts'
5import { LEVELS, type LevelConfig } from '../levels/LevelConfig.ts'
6import type { Canvas } from './Canvas.ts'
7
8export type Phase = 'LEVEL_SELECT' | 'PLAYING' | 'WIN' | 'FAIL'
9
10const PEN_SHRINK_RATE = 0.1
11const MIN_PEN_FRACTION = 0.05
12
13export 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}