herd / files / src / levels / LevelConfig.ts
LevelConfig.ts
4e15f13c 2026-05-17 Pavel Safronov | 1 | import type { GateSide } from '../entities/Pen.ts' |
| 2 | import type { SheepProps } from '../entities/Sheep.ts' | |
| 3 | ||
| 4 | export interface SpawnCluster { | |
| 5 | cx: number // fraction 0–1 of canvas width | |
| 6 | cy: number // fraction 0–1 of canvas height | |
| 7 | r: number // spread radius in fraction of min(w,h) | |
| 8 | count: number | |
| 9 | } | |
| 10 | ||
| 11 | export interface ObstacleDef { | |
| 12 | x: number; y: number; r: number // fractions of canvas size; r fraction of min(w,h) | |
| 13 | } | |
| 14 | ||
| 15 | export interface DynamicObstacleDef extends ObstacleDef { | |
| 16 | appearsAtSeconds: number | |
| 17 | } | |
| 18 | ||
| 19 | export interface SheepOverride extends SheepProps { | |
| 20 | index: number | |
| 21 | } | |
| 22 | ||
| 23 | export interface LevelConfig { | |
| 24 | id: number | |
| 25 | title: string | |
| 26 | subtitle: string | |
| 27 | ||
| 28 | sheepCount: number | |
| 29 | penRect: { x: number; y: number; w: number; h: number } // fractions of canvas | |
| 30 | penGateSide: GateSide | |
| 31 | timeLimitSeconds: number | null | |
b90608d0 2026-05-18 Pavel Safronov | 32 | winThreshold: number // integer count of sheep that must enter the pen (≤ sheepCount) |
4e15f13c 2026-05-17 Pavel Safronov | 33 | |
| 34 | spawnClusters: SpawnCluster[] | |
| 35 | obstacles: ObstacleDef[] | |
| 36 | dynamicObstacles: DynamicObstacleDef[] | |
| 37 | sheepOverrides: SheepOverride[] | |
| 38 | ||
| 39 | darkMode: boolean | |
| 40 | penShrinksEverySeconds: number | null | |
| 41 | ||
| 42 | // per-level dog speed override (null = default 160 px/s) | |
| 43 | dogSpeed: number | null | |
| 44 | // per-level sheep max speed multiplier (null = 1.0) | |
| 45 | sheepSpeedMult: number | null | |
| 46 | } | |
| 47 | ||
| 48 | export const LEVELS: LevelConfig[] = [ | |
| 49 | { | |
| 50 | id: 1, | |
| 51 | title: 'First Lesson', | |
| 52 | subtitle: '', | |
| 53 | sheepCount: 6, | |
| 54 | penRect: { x: 0.68, y: 0.05, w: 0.25, h: 0.25 }, | |
| 55 | penGateSide: 'bottom', | |
| 56 | timeLimitSeconds: 120, | |
b90608d0 2026-05-18 Pavel Safronov | 57 | winThreshold: 5, |
4e15f13c 2026-05-17 Pavel Safronov | 58 | spawnClusters: [{ cx: 0.5, cy: 0.55, r: 0.08, count: 6 }], |
| 59 | obstacles: [], | |
| 60 | dynamicObstacles: [], | |
| 61 | sheepOverrides: [], | |
| 62 | darkMode: false, | |
| 63 | penShrinksEverySeconds: null, | |
| 64 | dogSpeed: null, | |
| 65 | sheepSpeedMult: null, | |
| 66 | }, | |
| 67 | { | |
| 68 | id: 2, | |
| 69 | title: 'The Scatter', | |
| 70 | subtitle: 'Rural depopulation — communities spread too thin to self-organize.', | |
| 71 | sheepCount: 12, | |
| 72 | penRect: { x: 0.38, y: 0.78, w: 0.22, h: 0.18 }, | |
| 73 | penGateSide: 'top', | |
| 74 | timeLimitSeconds: 150, | |
b90608d0 2026-05-18 Pavel Safronov | 75 | winThreshold: 12, |
4e15f13c 2026-05-17 Pavel Safronov | 76 | spawnClusters: [ |
| 77 | { cx: 0.08, cy: 0.1, r: 0.04, count: 3 }, | |
| 78 | { cx: 0.92, cy: 0.1, r: 0.04, count: 3 }, | |
| 79 | { cx: 0.08, cy: 0.82, r: 0.04, count: 3 }, | |
| 80 | { cx: 0.92, cy: 0.82, r: 0.04, count: 3 }, | |
| 81 | ], | |
| 82 | obstacles: [], | |
| 83 | dynamicObstacles: [], | |
| 84 | sheepOverrides: [], | |
| 85 | darkMode: false, | |
| 86 | penShrinksEverySeconds: null, | |
| 87 | dogSpeed: null, | |
| 88 | sheepSpeedMult: null, | |
| 89 | }, | |
| 90 | { | |
| 91 | id: 3, | |
| 92 | title: 'The Holdouts', | |
| 93 | subtitle: 'Herd immunity — the last few require disproportionate effort.', | |
| 94 | sheepCount: 15, | |
| 95 | penRect: { x: 0.05, y: 0.35, w: 0.22, h: 0.28 }, | |
| 96 | penGateSide: 'right', | |
| 97 | timeLimitSeconds: 180, | |
b90608d0 2026-05-18 Pavel Safronov | 98 | winThreshold: 15, |
4e15f13c 2026-05-17 Pavel Safronov | 99 | spawnClusters: [ |
| 100 | { cx: 0.55, cy: 0.5, r: 0.12, count: 12 }, | |
| 101 | { cx: 0.88, cy: 0.12, r: 0.03, count: 1 }, | |
| 102 | { cx: 0.88, cy: 0.88, r: 0.03, count: 1 }, | |
| 103 | { cx: 0.5, cy: 0.12, r: 0.03, count: 1 }, | |
| 104 | ], | |
| 105 | obstacles: [], | |
| 106 | dynamicObstacles: [], | |
| 107 | sheepOverrides: [ | |
| 108 | { index: 12, stubborn: true }, | |
| 109 | { index: 13, stubborn: true }, | |
| 110 | { index: 14, stubborn: true }, | |
| 111 | ], | |
| 112 | darkMode: false, | |
| 113 | penShrinksEverySeconds: null, | |
| 114 | dogSpeed: null, | |
| 115 | sheepSpeedMult: null, | |
| 116 | }, | |
| 117 | { | |
| 118 | id: 4, | |
| 119 | title: 'Two Tribes', | |
| 120 | subtitle: 'Political polarization — two groups that actively repel each other.', | |
| 121 | sheepCount: 20, | |
| 122 | penRect: { x: 0.37, y: 0.35, w: 0.26, h: 0.28 }, | |
| 123 | penGateSide: 'left', | |
| 124 | timeLimitSeconds: 180, | |
b90608d0 2026-05-18 Pavel Safronov | 125 | winThreshold: 20, |
4e15f13c 2026-05-17 Pavel Safronov | 126 | spawnClusters: [ |
| 127 | { cx: 0.12, cy: 0.5, r: 0.08, count: 10 }, | |
| 128 | { cx: 0.88, cy: 0.5, r: 0.08, count: 10 }, | |
| 129 | ], | |
| 130 | obstacles: [], | |
| 131 | dynamicObstacles: [], | |
| 132 | sheepOverrides: [ | |
| 133 | ...Array.from({ length: 10 }, (_, i) => ({ index: i, subFlock: 0 })), | |
| 134 | ...Array.from({ length: 10 }, (_, i) => ({ index: i + 10, subFlock: 1 })), | |
| 135 | ], | |
| 136 | darkMode: false, | |
| 137 | penShrinksEverySeconds: null, | |
| 138 | dogSpeed: null, | |
| 139 | sheepSpeedMult: null, | |
| 140 | }, | |
| 141 | { | |
| 142 | id: 5, | |
| 143 | title: 'Shrinking Pasture', | |
| 144 | subtitle: 'Habitat loss — the environment erodes in real time.', | |
| 145 | sheepCount: 15, | |
| 146 | penRect: { x: 0.05, y: 0.05, w: 0.22, h: 0.22 }, | |
| 147 | penGateSide: 'right', | |
| 148 | timeLimitSeconds: 210, | |
b90608d0 2026-05-18 Pavel Safronov | 149 | winThreshold: 13, |
4e15f13c 2026-05-17 Pavel Safronov | 150 | spawnClusters: [{ cx: 0.55, cy: 0.55, r: 0.15, count: 15 }], |
| 151 | obstacles: [ | |
| 152 | { x: 0.88, y: 0.3, r: 0.04 }, | |
| 153 | { x: 0.88, y: 0.6, r: 0.04 }, | |
| 154 | { x: 0.88, y: 0.88, r: 0.04 }, | |
| 155 | ], | |
| 156 | dynamicObstacles: [ | |
| 157 | { x: 0.75, y: 0.15, r: 0.04, appearsAtSeconds: 30 }, | |
| 158 | { x: 0.75, y: 0.85, r: 0.04, appearsAtSeconds: 60 }, | |
| 159 | { x: 0.6, y: 0.88, r: 0.04, appearsAtSeconds: 90 }, | |
| 160 | { x: 0.62, y: 0.12, r: 0.04, appearsAtSeconds: 120 }, | |
| 161 | { x: 0.5, y: 0.82, r: 0.04, appearsAtSeconds: 150 }, | |
| 162 | { x: 0.5, y: 0.18, r: 0.04, appearsAtSeconds: 180 }, | |
| 163 | ], | |
| 164 | sheepOverrides: [], | |
| 165 | darkMode: false, | |
| 166 | penShrinksEverySeconds: null, | |
| 167 | dogSpeed: null, | |
| 168 | sheepSpeedMult: null, | |
| 169 | }, | |
| 170 | { | |
| 171 | id: 6, | |
| 172 | title: 'Already Spooked', | |
| 173 | subtitle: 'Displaced populations — heavy-handed intervention makes things worse.', | |
| 174 | sheepCount: 18, | |
| 175 | penRect: { x: 0.7, y: 0.7, w: 0.26, h: 0.24 }, | |
| 176 | penGateSide: 'top', | |
| 177 | timeLimitSeconds: 240, | |
b90608d0 2026-05-18 Pavel Safronov | 178 | winThreshold: 15, |
4e15f13c 2026-05-17 Pavel Safronov | 179 | spawnClusters: [ |
| 180 | { cx: 0.15, cy: 0.2, r: 0.05, count: 3 }, | |
| 181 | { cx: 0.5, cy: 0.15, r: 0.05, count: 3 }, | |
| 182 | { cx: 0.82, cy: 0.2, r: 0.05, count: 3 }, | |
| 183 | { cx: 0.15, cy: 0.75, r: 0.05, count: 3 }, | |
| 184 | { cx: 0.5, cy: 0.8, r: 0.05, count: 3 }, | |
| 185 | { cx: 0.82, cy: 0.4, r: 0.05, count: 3 }, | |
| 186 | ], | |
| 187 | obstacles: [], | |
| 188 | dynamicObstacles: [], | |
| 189 | sheepOverrides: Array.from({ length: 18 }, (_, i) => ({ index: i, startFear: 1.0 })), | |
| 190 | darkMode: false, | |
| 191 | penShrinksEverySeconds: null, | |
| 192 | dogSpeed: 110, | |
| 193 | sheepSpeedMult: null, | |
| 194 | }, | |
| 195 | { | |
| 196 | id: 7, | |
| 197 | title: 'The Influencers', | |
| 198 | subtitle: 'Opinion dynamics — redirect the few and the many follow.', | |
| 199 | sheepCount: 22, | |
| 200 | penRect: { x: 0.7, y: 0.3, w: 0.24, h: 0.38 }, | |
| 201 | penGateSide: 'left', | |
| 202 | timeLimitSeconds: 180, | |
b90608d0 2026-05-18 Pavel Safronov | 203 | winThreshold: 20, |
4e15f13c 2026-05-17 Pavel Safronov | 204 | spawnClusters: [{ cx: 0.25, cy: 0.5, r: 0.18, count: 22 }], |
| 205 | obstacles: [ | |
| 206 | { x: 0.5, y: 0.25, r: 0.04 }, | |
| 207 | { x: 0.5, y: 0.75, r: 0.04 }, | |
| 208 | ], | |
| 209 | dynamicObstacles: [], | |
| 210 | sheepOverrides: [ | |
| 211 | { index: 0, leader: true }, | |
| 212 | { index: 1, leader: true }, | |
| 213 | { index: 2, leader: true }, | |
| 214 | { index: 3, leader: true }, | |
| 215 | ], | |
| 216 | darkMode: false, | |
| 217 | penShrinksEverySeconds: null, | |
| 218 | dogSpeed: null, | |
| 219 | sheepSpeedMult: null, | |
| 220 | }, | |
| 221 | { | |
| 222 | id: 8, | |
| 223 | title: 'Race to Market', | |
| 224 | subtitle: 'Industrial agriculture — volume, speed, efficiency above all else.', | |
| 225 | sheepCount: 30, | |
| 226 | penRect: { x: 0.38, y: 0.05, w: 0.24, h: 0.3 }, | |
| 227 | penGateSide: 'bottom', | |
| 228 | timeLimitSeconds: 75, | |
b90608d0 2026-05-18 Pavel Safronov | 229 | winThreshold: 24, |
4e15f13c 2026-05-17 Pavel Safronov | 230 | spawnClusters: [ |
| 231 | { cx: 0.2, cy: 0.65, r: 0.12, count: 15 }, | |
| 232 | { cx: 0.8, cy: 0.65, r: 0.12, count: 15 }, | |
| 233 | ], | |
| 234 | obstacles: [], | |
| 235 | dynamicObstacles: [], | |
| 236 | sheepOverrides: [], | |
| 237 | darkMode: false, | |
| 238 | penShrinksEverySeconds: null, | |
| 239 | dogSpeed: 200, | |
| 240 | sheepSpeedMult: 1.2, | |
| 241 | }, | |
| 242 | { | |
| 243 | id: 9, | |
| 244 | title: 'Lights Out', | |
| 245 | subtitle: 'Information asymmetry — you can only see what is directly in front of you.', | |
| 246 | sheepCount: 16, | |
| 247 | penRect: { x: 0.38, y: 0.38, w: 0.24, h: 0.24 }, | |
| 248 | penGateSide: 'bottom', | |
| 249 | timeLimitSeconds: 270, | |
b90608d0 2026-05-18 Pavel Safronov | 250 | winThreshold: 16, |
4e15f13c 2026-05-17 Pavel Safronov | 251 | spawnClusters: [ |
| 252 | { cx: 0.1, cy: 0.1, r: 0.06, count: 2 }, | |
| 253 | { cx: 0.5, cy: 0.05, r: 0.06, count: 2 }, | |
| 254 | { cx: 0.9, cy: 0.1, r: 0.06, count: 2 }, | |
| 255 | { cx: 0.1, cy: 0.9, r: 0.06, count: 2 }, | |
| 256 | { cx: 0.5, cy: 0.9, r: 0.06, count: 2 }, | |
| 257 | { cx: 0.9, cy: 0.9, r: 0.06, count: 2 }, | |
| 258 | { cx: 0.1, cy: 0.5, r: 0.06, count: 1 }, | |
| 259 | { cx: 0.9, cy: 0.5, r: 0.06, count: 1 }, | |
| 260 | ], | |
| 261 | obstacles: [ | |
| 262 | { x: 0.25, y: 0.5, r: 0.03 }, | |
| 263 | { x: 0.75, y: 0.5, r: 0.03 }, | |
| 264 | ], | |
| 265 | dynamicObstacles: [], | |
| 266 | sheepOverrides: [], | |
| 267 | darkMode: true, | |
| 268 | penShrinksEverySeconds: null, | |
| 269 | dogSpeed: null, | |
| 270 | sheepSpeedMult: null, | |
| 271 | }, | |
| 272 | { | |
| 273 | id: 10, | |
| 274 | title: 'The Last Pasture', | |
| 275 | subtitle: 'Climate crisis — the window closes slowly, then all at once.', | |
| 276 | sheepCount: 25, | |
| 277 | penRect: { x: 0.65, y: 0.05, w: 0.3, h: 0.35 }, | |
| 278 | penGateSide: 'bottom', | |
| 279 | timeLimitSeconds: null, | |
b90608d0 2026-05-18 Pavel Safronov | 280 | winThreshold: 25, |
4e15f13c 2026-05-17 Pavel Safronov | 281 | spawnClusters: [ |
| 282 | { cx: 0.2, cy: 0.45, r: 0.12, count: 13 }, | |
| 283 | { cx: 0.45, cy: 0.8, r: 0.12, count: 12 }, | |
| 284 | ], | |
| 285 | obstacles: [ | |
| 286 | { x: 0.5, y: 0.3, r: 0.1 }, // dried-out lake blocking direct route | |
| 287 | ], | |
| 288 | dynamicObstacles: [], | |
| 289 | sheepOverrides: [ | |
| 290 | { index: 0, stubborn: true }, | |
| 291 | { index: 1, stubborn: true }, | |
| 292 | { index: 2, stubborn: true }, | |
| 293 | { index: 23, leader: true }, | |
| 294 | { index: 24, leader: true }, | |
| 295 | ], | |
| 296 | darkMode: false, | |
| 297 | penShrinksEverySeconds: 25, | |
| 298 | dogSpeed: null, | |
| 299 | sheepSpeedMult: null, | |
| 300 | }, | |
| 301 | ] |