herd / files / src / levels / LevelConfig.ts
LevelConfig.ts
| 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 |
| 32 | winThreshold: number // integer count of sheep that must enter the pen (≤ sheepCount) |
| 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, |
| 57 | winThreshold: 5, |
| 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, |
| 75 | winThreshold: 12, |
| 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, |
| 98 | winThreshold: 15, |
| 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, |
| 125 | winThreshold: 20, |
| 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, |
| 149 | winThreshold: 13, |
| 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, |
| 178 | winThreshold: 15, |
| 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, |
| 203 | winThreshold: 20, |
| 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, |
| 229 | winThreshold: 24, |
| 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, |
| 250 | winThreshold: 16, |
| 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, |
| 280 | winThreshold: 25, |
| 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 | ] |