A RollerCoaster Tycoon-style park that Claude builds while it works: tool calls lay track, coasters get test runs, crowds pour in, and parks compete on a…

Claude builds you a RollerCoaster Tycoon-style theme park while it works. Full description, screenshots and install steps: the repository README.
This file states exactly what the mod reads, runs, contacts and sends.
All hooks pass the event on unchanged. The mod never blocks, rewrites or delays a tool call, a prompt or a model response.
| Hook | What it reads | What it does with it |
|---|---|---|
tool.call | The tool's name (Edit, Bash, …), which loop made the call, and whether the result was an error | Lays track pieces in the park; failed calls count against the ride's test. It reads no tool input or output (no file contents, commands or results). |
turn.step | The model name of each model request (main loop or subagent) | Picks the construction crew: Opus/Fable → coasters, Sonnet → family rides, Haiku → flat rides. It reads no messages; the stream passes through untouched. |
turn.start, turn.complete | That a turn started, and the turn's end reason (answer, aborted, error) | Marks the crews working or idle, and finishes or crash-tests unfinished rides. |
command.run (/park) | The arguments typed after /park | Runs the /park subcommands: open, top, join, leave, name, reset. |
session.start, session.end | Nothing | Loads and saves the park, starts the animation timer, opens the pane. |
ui.render (its own pane) | Nothing beyond its own state | Draws the park and the leaderboard. |
| Program | When | Why |
|---|---|---|
git rev-parse --show-toplevel | Once per session | Finds the project's root folder, so each repository gets its own park. |
gh api user --jq .login | Only when you type /park join | Gets your GitHub username. |
gh gist create --public --filename park.json … | Once, on your first /park join | Creates the public gist that holds your parks' stats. |
gh issue create --repo thickiran/claude-coaster-tycoon … | Once, on your first /park join | Registers that gist with the leaderboard. |
gh api gists/<your gist id> … and gh api -X PATCH gists/<your gist id> … | Every 10 minutes, only for parks you joined | Reads and updates your park's stats in your own gist. |
gh is the GitHub CLI, using the login you set up with gh auth login (the only credential the mod uses, and only after /park join). The mod never reads, stores or sends the token itself; gh does the authenticating.
raw.githubusercontent.com: a GET of https://raw.githubusercontent.com/thickiran/claude-coaster-tycoon/main/leaderboard/leaderboard.json every 5 minutes, to show the leaderboard. This request sends nothing about you.api.github.com: only through gh, only after /park join, as listed above.Nothing, until you type /park join for a park. After that, every 10 minutes, it writes these fields for that park to a public gist on your own GitHub account:
id (a hash of the project folder's path, not the path itself), name (the park's display name, which defaults to the folder name; change it with /park name), rating, guests, guestsTotal, rides, ridesOpen, coasters, riders, money, best (the best ride's generated name, kind and rating), updatedAt.
Your GitHub username becomes public on the leaderboard. The mod sends no file paths, file contents, code, prompts, conversation text, tool inputs or tool outputs. /park leave removes the park from your gist.
The park itself (rides, guests, money) in Claude Code's per-plugin store, one entry per project, plus your GitHub username and gist id after joining.
hooks/register.tsx 449 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Leaderboard, ParkBoard, ParkView } from '../types'
5import { GIST_FILE, ago, cleanName, pad, parkId, parkStats } from './board'
6import type { ParkStats } from './board'
7import { renderFrame, toRasterCells, toSvg } from './draw'
8import { renderSvg } from './vector'
9import {
10 buildingRides, createWorld, loadWorld, parkRating, ratingLabel, saveWorld, step,
11 toolCalled, toolFailed, turnEnded, turnStarted,
12} from './sim'
13import type { World } from './sim'
14
15const PANE = 'coaster-park'
16const DT = 0.15
17const PUSH_MS = 10 * 60 * 1000
18const FETCH_MS = 5 * 60 * 1000
19const tick = atom({ plugin: 'coaster-tycoon', key: 'tick' } as const, 0)
20const view = atom({ plugin: 'coaster-tycoon', key: 'view' } as const, 'park' as ParkView)
21const board = atom({ plugin: 'coaster-tycoon', key: 'board' } as const, { board: null, fetchedAt: 0, error: '' } as ParkBoard)
22
23type Account = { user: string; gist: string }
24
25const money = (n: number) => `${n < 0 ? '-' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`
26let world: World | undefined
27let isBusy = false
28let frames = 0
29let lastStatus = ''
30let myRank = 0
31// The model each loop last stepped with: 'main', or a subagent's id.
32const models = new Map<string, string>()
33
34// One park per project: the git repository's root when there is one (so its
35// subfolders share a park), else the folder the session started in.
36let parkKey = ''
37let parkName = 'Claude Park'
38let myParkId = ''
39
40async function project($: EngineInterface): Promise<string> {
41 if (parkKey) return parkKey
42 const cwd = await $.session.cwd()
43 let root = cwd
44 try {
45 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 3000 })
46 const top = git.stdout.trim()
47 if (git.exitCode === 0 && top) root = top
48 } catch {
49 // Not a repository, or no git: the folder itself is the project.
50 }
51 parkKey = `park:${root}`
52 myParkId = await parkId(root)
53 const base = root.split('/').filter(Boolean).pop() ?? 'Claude'
54 parkName = `${base} Park`
55
56 return parkKey
57}
58
59async function park($: EngineInterface): Promise<World> {
60 if (!world) {
61 const key = await project($)
62 let saved = await $.store.get(key)
63 if (!saved) {
64 // The park from before parks were per project moves into this one.
65 saved = await $.store.get('park')
66 if (saved) await $.store.delete('park')
67 }
68 world = saved ? loadWorld(saved) : createWorld()
69 }
70
71 return world
72}
73
74function displayName(w: World): string {
75 return w.shareName || parkName
76}
77
78async function save($: EngineInterface) {
79 if (!world) return
80 world.isDirty = false
81 await $.store.set(await project($), saveWorld(world))
82}
83
84async function resetPark($: EngineInterface) {
85 const was = world
86 world = createWorld()
87 if (was) {
88 world.isShared = was.isShared
89 world.shareName = was.shareName
90 }
91 await save($)
92}
93
94async function frame($: EngineInterface) {
95 if (isBusy || !world) return
96 isBusy = true
97 try {
98 step(world, DT)
99 frames += 1
100 for (const text of world.toasts.splice(0)) $.ui.toast(text, { timeoutMs: 6000 })
101 if (frames % 14 === 0) {
102 const open = world.rides.filter(r => r.status === 'open').length
103 const rank = myRank > 0 && world.isShared ? `🏆 #${myRank} · ` : ''
104 const status = `${rank}🎢 ${open} ride${open === 1 ? '' : 's'} · 👥 ${world.guests.length} · ${money(world.money)}`
105 if (status !== lastStatus) {
106 lastStatus = status
107 $.ui.status(status)
108 }
109 }
110 if ((world.isDirty && frames % 20 === 0) || frames % 200 === 0) await save($)
111 await update($, tick, n => (n + 1) % 1_000_000)
112 } finally {
113 isBusy = false
114 }
115}
116
117// ---- The global leaderboard ---------------------------------------------
118// A joined park lives in a public gist of the player's own (through their
119// `gh` login); the repository's Action reads every registered gist and
120// publishes the ranking, which the pane fetches.
121
122// Every program the mod runs is written out in full at its call: `git` once
123// to find the project, and `gh` (the player's GitHub CLI) for the
124// leaderboard, only after `/park join`. See the plugin's README.
125const GH = { timeoutMs: 20_000 }
126
127async function account($: EngineInterface): Promise<Account | undefined> {
128 const saved = (await $.store.get('account')) as Account | undefined
129
130 return saved && typeof saved.user === 'string' && typeof saved.gist === 'string' ? saved : undefined
131}
132
133async function readGist($: EngineInterface, gist: string): Promise<Record<string, ParkStats>> {
134 const got = await $.process.run(['gh', 'api', `gists/${gist}`, '--jq', '.files["park.json"].content'], GH)
135 if (got.exitCode !== 0) throw new Error(got.stderr.trim() || 'could not read the park gist')
136 try {
137 const parsed = JSON.parse(got.stdout || '{}') as { parks?: Record<string, ParkStats> }
138 return parsed.parks ?? {}
139 } catch {
140 return {}
141 }
142}
143
144async function writeGist($: EngineInterface, gist: string, parks: Record<string, ParkStats>) {
145 const content = JSON.stringify({ v: 1, parks }, null, 2)
146 const body = JSON.stringify({ files: { [GIST_FILE]: { content } } })
147 const sent = await $.process.run(['gh', 'api', '-X', 'PATCH', `gists/${gist}`, '--input', '-'], { ...GH, stdin: body })
148 if (sent.exitCode !== 0) throw new Error(sent.stderr.trim() || 'could not update the park gist')
149}
150
151async function pushPark($: EngineInterface) {
152 const w = await park($)
153 const acct = await account($)
154 if (!w.isShared || !acct) return
155 try {
156 const parks = await readGist($, acct.gist)
157 parks[myParkId] = parkStats(w, myParkId, displayName(w))
158 await writeGist($, acct.gist, parks)
159 } catch {
160 // Offline or gh signed out: the next period tries again.
161 }
162}
163
164async function fetchBoard($: EngineInterface) {
165 try {
166 const got = await $.http.fetch('https://raw.githubusercontent.com/thickiran/claude-coaster-tycoon/main/leaderboard/leaderboard.json')
167 if (!got.ok) throw new Error(`HTTP ${got.status}`)
168 const parsed = JSON.parse(got.text) as Leaderboard
169 const acct = await account($)
170 const mine = acct ? parsed.entries.find(x => x.user === acct.user && x.id === myParkId) : undefined
171 myRank = mine?.rank ?? 0
172 await update($, board, () => ({ board: parsed, fetchedAt: Date.now(), error: '' }))
173 } catch (err) {
174 await update($, board, b => ({ ...b, fetchedAt: Date.now(), error: err instanceof Error ? err.message : 'could not fetch' }))
175 }
176}
177
178async function join($: EngineInterface, name: string): Promise<string> {
179 const w = await park($)
180 if (name) w.shareName = cleanName(name)
181 const me = await $.process.run(['gh', 'api', 'user', '--jq', '.login'], GH).catch(() => undefined)
182 if (!me || me.exitCode !== 0 || !me.stdout.trim()) {
183 return 'Joining needs the GitHub CLI, signed in: install `gh` from https://cli.github.com and run `gh auth login`, then `/park join` again.'
184 }
185 const user = me.stdout.trim()
186 let acct = await account($)
187 const stats = parkStats(w, myParkId, displayName(w))
188 if (!acct || acct.user !== user) {
189 const first = JSON.stringify({ v: 1, parks: { [myParkId]: stats } }, null, 2)
190 const made = await $.process.run(
191 ['gh', 'gist', 'create', '--public', '--filename', 'park.json', '--desc', 'My Claude Code Coaster Tycoon parks (github.com/thickiran/claude-coaster-tycoon)', '-'],
192 { ...GH, stdin: first },
193 )
194 const url = made.stdout.trim().split('\n').pop() ?? ''
195 const gist = url.split('/').pop() ?? ''
196 if (made.exitCode !== 0 || !/^[0-9a-f]{20,40}$/.test(gist)) {
197 return `Could not create your park gist: ${made.stderr.trim() || 'unknown error'}`
198 }
199 const filed = await $.process.run([
200 'gh', 'issue', 'create', '--repo', 'thickiran/claude-coaster-tycoon', '--title', `[park] join: ${user}`,
201 '--body', `Registering my park gist for the global leaderboard.\n\n\`\`\`json\n${JSON.stringify({ gist })}\n\`\`\``,
202 ], GH)
203 if (filed.exitCode !== 0) return `Your park gist is up, but registering it failed: ${filed.stderr.trim()}`
204 acct = { user, gist }
205 await $.store.set('account', acct)
206 }
207 w.isShared = true
208 w.isDirty = true
209 await save($)
210 await pushPark($)
211
212 return `🏆 ${displayName(w)} joined the global leaderboard as ${user}. Scores update every 10 minutes, and the board refreshes about every 20. Open it with the Leaderboard button or \`/park top\`. Only game stats and the park's name are shared; \`/park leave\` takes it off.`
213}
214
215async function leave($: EngineInterface): Promise<string> {
216 const w = await park($)
217 const acct = await account($)
218 w.isShared = false
219 await save($)
220 if (acct) {
221 try {
222 const parks = await readGist($, acct.gist)
223 delete parks[myParkId]
224 await writeGist($, acct.gist, parks)
225 } catch {
226 return `${displayName(w)} stopped sharing, but its last stats could not be removed from your gist just now.`
227 }
228 }
229 myRank = 0
230
231 return `${displayName(w)} is off the leaderboard. It drops out at the board's next update.`
232}
233
234async function showBoard($: EngineInterface) {
235 await update($, view, () => 'leaderboard')
236 const b = await read($, board)
237 if (Date.now() - b.fetchedAt > 60_000) await fetchBoard($)
238}
239
240const USAGE = '`/park` opens it · `/park top` leaderboard · `/park join [name]` · `/park name <name>` · `/park leave` · `/park reset`'
241
242export const register: Register = on => {
243 on('session.start', async ($, e, next) => {
244 await park($)
245 await $.command.register({
246 name: 'park',
247 description: 'Your Coaster Tycoon park: top, join, leave, name, reset',
248 argumentHint: '[top | join [name] | leave | name <name> | reset]',
249 immediate: true,
250 })
251 $.clock.every(DT * 1000, () => void frame($))
252 $.clock.every(PUSH_MS, () => void pushPark($))
253 $.clock.every(FETCH_MS, () => void fetchBoard($))
254 void fetchBoard($)
255 void $.ui.open({ id: PANE, title: parkName, rows: 26 })
256
257 return next(e)
258 })
259
260 on('command.run', { command: 'park' }, async ($, e) => {
261 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
262 const arg = rest.join(' ')
263 const w = await park($)
264 const openPane = () => $.ui.open({ id: PANE, title: parkName, rows: 26, focus: true })
265 switch (sub) {
266 case 'reset':
267 await resetPark($)
268 await openPane()
269 return { text: `${displayName(w)} was bulldozed. Fresh grass, $10,000, and hungry construction crews.` }
270 case 'join':
271 return { text: await join($, arg) }
272 case 'leave':
273 return { text: await leave($) }
274 case 'name':
275 if (!arg) return { text: `This park is called ${displayName(w)}. Rename it with \`/park name <name>\`.` }
276 w.shareName = cleanName(arg)
277 await save($)
278 await pushPark($)
279 return { text: `Renamed to ${w.shareName}.` }
280 case 'top':
281 case 'leaderboard':
282 await openPane()
283 await showBoard($)
284 return { text: 'Opened the global leaderboard.' }
285 case '':
286 await update($, view, () => 'park')
287 await openPane()
288 return { text: `Welcome to ${displayName(w)}. ${USAGE}` }
289 default:
290 return { text: USAGE }
291 }
292 })
293
294 on('turn.start', async ($, e, next) => {
295 turnStarted(await park($))
296
297 return next(e)
298 })
299
300 on('turn.step', async function* ($, e, next) {
301 models.set(e.agentId ?? 'main', e.model)
302
303 return yield* next(e)
304 })
305
306 on('tool.call', async ($, e, next) => {
307 const w = await park($)
308 const model = models.get(e.agentId ?? 'main') ?? models.get('main') ?? 'opus'
309 const rideName = toolCalled(w, e.tool, model)
310 const ran = await next(e)
311 if (rideName && ran.deny === undefined && ran.isError === true) toolFailed(w, rideName)
312
313 return ran
314 })
315
316 on('turn.complete', async ($, e, next) => {
317 turnEnded(await park($), e.reason)
318
319 return next(e)
320 })
321
322 on('session.end', async ($, e, next) => {
323 await save($)
324 await pushPark($)
325
326 return next(e)
327 })
328
329 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330 await read($, tick)
331 const w = await park($)
332 const showing = await read($, view)
333 const els = $.ui.resolve(e) as Record<string, (props: object) => unknown>
334 const Box = els.Box as any
335 const Text = els.Text as any
336 const Button = els.Button as any
337 const open = w.rides.filter(r => r.status === 'open')
338
339 if (showing === 'leaderboard') {
340 const b = await read($, board)
341 const acct = await account($)
342 const cols: [string, number, boolean][] = [['#', 4, true], ['Player', 16, false], ['Park', 22, false], ['Score', 7, true], ['⭐', 5, true], ['Guests', 7, true], ['Rides', 6, true], ['Updated', 10, true]]
343 const row = (cells: (string | number)[], isMine: boolean, isHead = false) => (
344 <Box flexDirection="row">
345 {cells.map((c, i) => (
346 <Box width={cols[i]![1] + 1}>
347 <Text bold={isHead || isMine} dimColor={isHead} color={isMine ? '#f0d020' : undefined} wrap="truncate">
348 {pad(c, cols[i]![1], cols[i]![2])}
349 </Text>
350 </Box>
351 ))}
352 </Box>
353 )
354 const entries = b.board?.entries.slice(0, 20) ?? []
355
356 return (
357 <Box flexDirection="column">
358 <Box flexDirection="row" justifyContent="space-between">
359 <Text bold>🏆 Global leaderboard{b.board ? ` · ${b.board.players} players · updated ${ago(b.board.updatedAt, Date.now())}` : ''}</Text>
360 <Box flexDirection="row" gap={1}>
361 <Button key="refresh" label="Refresh" onPress={() => fetchBoard($)} />
362 <Button key="to-park" label="Back to park" variant="primary" onPress={() => update($, view, () => 'park')} />
363 </Box>
364 </Box>
365 {row(cols.map(c => c[0]), false, true)}
366 {entries.map(x => row(
367 [x.rank, x.user, x.name, x.score.toLocaleString('en-US'), x.rating, x.guestsTotal.toLocaleString('en-US'), x.ridesOpen, ago(x.updatedAt, Date.now())],
368 acct?.user === x.user && x.id === myParkId,
369 ))}
370 {entries.length === 0 && <Text dimColor>{b.error ? `Could not load the board: ${b.error}` : b.board ? 'No parks yet. Be the first: /park join' : 'Loading...'}</Text>}
371 <Text dimColor>
372 {w.isShared
373 ? `You're playing as ${acct?.user ?? '?'} with ${displayName(w)}${myRank ? `, rank #${myRank}` : ', waiting for the next board update'}.`
374 : 'This park is not on the board. Type /park join [name] to enter it. Only game stats and the park name are shared.'}
375 </Text>
376 </Box>
377 )
378 }
379
380 const crews = buildingRides(w)
381 const testing = w.rides.filter(r => r.status === 'testing')
382 const hasNews = w.time - w.newsAt < 7 || !w.thought
383 const ticker = hasNews ? `📰 ${w.news}` : `💭 ${w.thought}`
384 const jobs = [
385 ...testing.map(r => `🧪 Testing ${r.name}`),
386 ...crews.map(r => `🚧 ${r.builtBy} crew: ${r.name} (${r.kind}) ${Math.min(r.built, r.size)}/${r.size}${w.isWorking ? '' : ', finishing up'}`),
387 ]
388 const job = jobs.length > 0
389 ? jobs.join(' · ')
390 : w.isWorking ? '🚧 Crews standing by for the next ride' : '🌳 Crews on break. Give Claude work to build more rides.'
391 const header = (
392 <Box flexDirection="row" justifyContent="space-between">
393 <Text bold>{displayName(w)}</Text>
394 <Text bold color="#f0d020">{money(w.money)}</Text>
395 <Text>👥 {w.guests.length} in park ({w.guestsTotal} total)</Text>
396 <Text>⭐ {parkRating(w)}</Text>
397 <Text>🎢 {open.length} open · {w.cols * 2} plots</Text>
398 <Button key="to-board" label={myRank && w.isShared ? `🏆 #${myRank}` : '🏆 Leaderboard'} onPress={() => showBoard($)} />
399 </Box>
400 )
401
402 if (e.surface === 'terminal') {
403 const Raster = els.Raster as any
404 const cols = Math.max(24, Math.min(512, e.props.bodyColumns))
405 const rows = Math.max(8, Math.min(120, e.props.scroll.bodyRows - 4))
406 const px = renderFrame(w, cols, rows * 2, 4)
407
408 return (
409 <Box flexDirection="column">
410 {header}
411 <Raster key="park" columns={cols} rows={rows} cells={toRasterCells(px, cols, rows)} />
412 <Text wrap="truncate">{job}</Text>
413 <Text wrap="truncate" dimColor>{ticker}</Text>
414 </Box>
415 )
416 }
417
418 const Svg = els.Svg as any
419 const W = 220
420 const H = 150
421 let svg = renderSvg(w, 6)
422 if (svg.length > 130_000) svg = toSvg(renderFrame(w, W, H), W, H, 4)
423 if (svg.length > 130_000) svg = toSvg(renderFrame(w, 160, 110), 160, 110, 5.5)
424 const best = [...w.rides].sort((a, b) => b.excitement - a.excitement)
425
426 return (
427 <Box flexDirection="column" gap={1}>
428 {header}
429 <Svg source={svg} alt={`${displayName(w)}: ${open.length} rides open, ${w.guests.length} guests`} />
430 <Text>{job}</Text>
431 <Text dimColor>{ticker}</Text>
432 {best.length > 0 && (
433 <Box flexDirection="column">
434 {best.map(r => (
435 <Text wrap="truncate">
436 <Text bold>{r.name}</Text>
437 <Text dimColor> {r.tier === 'big' ? '🎢' : r.tier === 'medium' ? '🎠' : '🎡'} {r.kind} by {r.builtBy} · </Text>
438 {r.status === 'open'
439 ? `E ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)}) · I ${r.intensity.toFixed(2)} · N ${r.nausea.toFixed(2)} · $${r.price} · ${r.riders} riders`
440 : r.status === 'crashed' ? '💥 crashed, awaiting repairs' : r.status === 'testing' ? '🧪 testing' : '🚧 under construction'}
441 </Text>
442 ))}
443 </Box>
444 )}
445 </Box>
446 )
447 })
448}
449hooks/board.ts 78 lines1// The global leaderboard: what a shared park publishes, and how the board
2// reads back. The repository's GitHub Action ranks the parks with the same
3// score formula (scripts/update-leaderboard.mjs); keep the two in step.
4
5import { parkRating } from './sim'
6import type { World } from './sim'
7
8export const GIST_FILE = 'park.json'
9
10export type ParkStats = {
11 id: string
12 name: string
13 rating: number
14 guests: number
15 guestsTotal: number
16 rides: number
17 ridesOpen: number
18 coasters: number
19 riders: number
20 money: number
21 best: string
22 updatedAt: string
23}
24
25// Only game numbers and the park's display name leave the machine: never a
26// path, a file, a prompt or a line of code.
27export function parkStats(w: World, id: string, name: string): ParkStats {
28 const open = w.rides.filter(r => r.status === 'open')
29 const best = [...open].sort((a, b) => b.excitement - a.excitement)[0]
30
31 return {
32 id,
33 name: cleanName(name),
34 rating: parkRating(w),
35 guests: w.guests.length,
36 guestsTotal: w.guestsTotal,
37 rides: w.rides.length,
38 ridesOpen: open.length,
39 coasters: open.filter(r => r.tier === 'big').length,
40 riders: w.rides.reduce((a, r) => a + r.riders, 0),
41 money: Math.round(w.money),
42 best: best ? `${best.name} (${best.kind}, E ${best.excitement.toFixed(2)})` : '',
43 updatedAt: new Date().toISOString(),
44 }
45}
46
47export function score(p: Pick<ParkStats, 'rating' | 'ridesOpen' | 'guestsTotal' | 'riders'>): number {
48 return Math.round(p.rating * (1 + 0.25 * p.ridesOpen) + p.guestsTotal * 0.05 + p.riders * 0.02)
49}
50
51export function cleanName(name: string): string {
52 return name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 40) || 'Unnamed Park'
53}
54
55export async function parkId(root: string): Promise<string> {
56 const bytes = new TextEncoder().encode(`coaster-tycoon:${root}`)
57 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
58
59 return [...digest.slice(0, 6)].map(b => b.toString(16).padStart(2, '0')).join('')
60}
61
62export function ago(iso: string, now: number): string {
63 const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000))
64 if (!Number.isFinite(s)) return '?'
65 if (s < 90) return 'just now'
66 if (s < 5400) return `${Math.round(s / 60)}m ago`
67 if (s < 129600) return `${Math.round(s / 3600)}h ago`
68
69 return `${Math.round(s / 86400)}d ago`
70}
71
72export function pad(text: string | number, width: number, isRight = false): string {
73 const t = String(text)
74 const cut = t.length > width ? `${t.slice(0, width - 1)}…` : t
75
76 return isRight ? cut.padStart(width) : cut.padEnd(width)
77}
78hooks/draw.ts 432 lines1// Paints the park into a pixel buffer (0xRRGGBB per pixel) in the original
2// game's isometric style, then packs it for the terminal (half-block Raster
3// cells) or the desktop (an SVG of horizontal runs).
4
5import { BLVD, GY, footprint, gridW, nodeAt, queueTile } from './sim'
6import type { Ride, World } from './sim'
7
8type View = { W: number; H: number; a: number; b: number; hs: number; ox: number; oy: number }
9
10const BG = 0x0e1210
11const GRASS = [0x5aa02e, 0x4f9428]
12const GRASS_SPECK = 0x6cb43a
13const DIRT = [0x9a7444, 0x8c6a3c]
14const PATH = [0xa2a2a2, 0x989898]
15const QUEUE = [0xc09868, 0xb48c5c]
16const CLIFF = [0x7a5a30, 0x5e4424, 0x4a361c]
17const EDGE_DARK = 0.86
18
19// Fits the whole park when it can; otherwise zooms to `minA` and follows the
20// camera, as the original's scrolling viewport did.
21function makeView(W: number, H: number, minA: number, camX: number, camY: number, GX: number): View {
22 const span = GX + GY
23 const cliff = 3
24 const fit = Math.max(1.2, Math.min((W - 2) / span, (H - cliff - 2) / (span / 2 + 5.5)))
25 const a = Math.max(fit, minA)
26 const b = a / 2
27 const hs = a * 0.62
28 if (a === fit) {
29 const ox = Math.round(W / 2 - ((GX - GY) * a) / 2)
30 const oy = Math.round(H - cliff - span * b - 1)
31
32 return { W, H, a, b, hs, ox, oy }
33 }
34 const ox = Math.round(W / 2 - (camX - camY) * a)
35 const oy = Math.round(H / 2 - (camX + camY) * b + 1.5 * hs)
36
37 return { W, H, a, b, hs, ox, oy }
38}
39
40const proj = (v: View, x: number, y: number, h: number): [number, number] => [
41 Math.round(v.ox + (x - y) * v.a),
42 Math.round(v.oy + (x + y) * v.b - h * v.hs),
43]
44
45export function shade(c: number, k: number): number {
46 const r = Math.min(255, Math.round(((c >> 16) & 255) * k))
47 const g = Math.min(255, Math.round(((c >> 8) & 255) * k))
48 const b = Math.min(255, Math.round((c & 255) * k))
49
50 return (r << 16) | (g << 8) | b
51}
52
53function hash(x: number, y: number): number {
54 let h = (x * 374761393 + y * 668265263) | 0
55 h = Math.imul(h ^ (h >>> 13), 1274126177)
56
57 return ((h ^ (h >>> 16)) >>> 0) / 4294967296
58}
59
60// Tile kinds: 0 grass, 1 path, 2 queue, 3 construction dirt.
61export function tileMap(w: World): Uint8Array {
62 const GX = gridW(w)
63 const t = new Uint8Array(GX * GY)
64 for (let x = 0; x < GX; x++) t[BLVD * GX + x] = 1
65 for (const r of w.rides) {
66 const q = queueTile(r)
67 t[q.y * GX + q.x] = 2
68 if (r.status === 'building') {
69 const s = footprint(r)
70 for (let y = s.y0; y < s.y0 + s.h; y++) for (let x = s.x0; x < s.x0 + s.w; x++) t[y * GX + x] = 3
71 }
72 }
73
74 return t
75}
76
77let groundKey = ''
78let groundBuf = new Uint32Array(0)
79
80function ground(w: World, v: View): Uint32Array {
81 const key = `${v.W}x${v.H}:${v.a}:${v.ox}:${v.oy}:${w.groundVer}:${w.rides.map(r => r.status[0]).join('')}`
82 if (key === groundKey) return groundBuf
83 const tiles = tileMap(w)
84 const GX = gridW(w)
85 const buf = new Uint32Array(v.W * v.H)
86 const edge = v.a >= 5 ? 0.9 / v.a : 0
87 const drop = Math.max(2, Math.round(v.a * 0.8))
88 const onMap = (tx: number, ty: number) => tx >= 0 && ty >= 0 && tx < GX && ty < GY
89 for (let py = 0; py < v.H; py++) {
90 for (let px = 0; px < v.W; px++) {
91 const u = (px + 0.5 - v.ox) / v.a
92 const vv = (py + 0.5 - v.oy) / v.b
93 const tx = (u + vv) / 2
94 const ty = (vv - u) / 2
95 let c = BG
96 if (onMap(tx, ty)) {
97 const ix = Math.floor(tx)
98 const iy = Math.floor(ty)
99 const kind = tiles[iy * GX + ix]!
100 const odd = (ix + iy) & 1
101 c = kind === 1 ? PATH[odd]! : kind === 2 ? QUEUE[odd]! : kind === 3 ? DIRT[odd]! : GRASS[odd]!
102 if (kind === 0 && hash(px, py) < 0.06) c = GRASS_SPECK
103 if (edge > 0 && (tx - ix < edge || ty - iy < edge)) c = shade(c, EDGE_DARK)
104 if (kind === 0 && ix === 0 && iy === BLVD) c = PATH[0]!
105 } else {
106 for (let k = 1; k <= drop; k++) {
107 const v2 = (py + 0.5 - k - v.oy) / v.b
108 if (onMap((u + v2) / 2, (v2 - u) / 2)) {
109 c = CLIFF[Math.min(2, Math.floor(((k - 1) / drop) * 3))]!
110 if (u < 0) c = shade(c, 0.8)
111 break
112 }
113 }
114 }
115 buf[py * v.W + px] = c
116 }
117 }
118 groundKey = key
119 groundBuf = buf
120
121 return buf
122}
123
124class Canvas {
125 readonly v: View
126 readonly px: Uint32Array
127
128 constructor(v: View, px: Uint32Array) {
129 this.v = v
130 this.px = px
131 }
132
133 set(x: number, y: number, c: number) {
134 if (x < 0 || y < 0 || x >= this.v.W || y >= this.v.H) return
135 this.px[y * this.v.W + x] = c
136 }
137
138 line(x0: number, y0: number, x1: number, y1: number, c: number) {
139 const dx = Math.abs(x1 - x0)
140 const dy = -Math.abs(y1 - y0)
141 const sx = x0 < x1 ? 1 : -1
142 const sy = y0 < y1 ? 1 : -1
143 let err = dx + dy
144 for (let guard = 0; guard < 2000; guard++) {
145 this.set(x0, y0, c)
146 if (x0 === x1 && y0 === y1) break
147 const e2 = 2 * err
148 if (e2 >= dy) {
149 err += dy
150 x0 += sx
151 }
152 if (e2 <= dx) {
153 err += dx
154 y0 += sy
155 }
156 }
157 }
158
159 disc(cx: number, cy: number, r: number, c: number) {
160 const rr = Math.ceil(r)
161 for (let y = -rr; y <= rr; y++) {
162 for (let x = -rr; x <= rr; x++) {
163 if (x * x + y * y <= r * r + 0.3) this.set(cx + x, cy + y, c)
164 }
165 }
166 }
167}
168
169type Sprite = { d: number; draw: () => void }
170
171export function renderFrame(w: World, W: number, H: number, minA = 0): Uint32Array {
172 const v = makeView(W, H, minA, w.camX, w.camY, gridW(w))
173 const cv = new Canvas(v, ground(w, v).slice())
174 const big = v.a >= 5
175 const sprites: Sprite[] = []
176 const add = (d: number, draw: () => void) => sprites.push({ d, draw })
177
178 for (const f of w.flowers) {
179 add(f.x + f.y - 0.5, () => {
180 const [x, y] = proj(v, f.x, f.y, 0)
181 cv.set(x, y, f.c)
182 })
183 }
184
185 for (const t of w.trees) {
186 add(t.x + t.y, () => {
187 const [x, y] = proj(v, t.x, t.y, 0)
188 const trunk = Math.max(1, Math.round(v.a * 0.35))
189 const r = Math.max(0.8, v.a * 0.5)
190 cv.line(x, y, x, y - trunk, 0x6a4424)
191 cv.disc(x, y - trunk - Math.round(r), r, 0x24642a)
192 if (big) {
193 cv.disc(x - 1, y - trunk - Math.round(r) - 1, r * 0.45, 0x3a8a34)
194 }
195 })
196 }
197
198 // The park entrance: two posts and a banner on the left edge.
199 add(BLVD + 0.2, () => {
200 const [ax, ay] = proj(v, 0.05, BLVD + 0.05, 0)
201 const [bx, by] = proj(v, 0.05, BLVD + 0.95, 0)
202 const tall = Math.max(3, Math.round(v.hs * 2))
203 cv.line(ax, ay, ax, ay - tall, 0xe8e8e8)
204 cv.line(bx, by, bx, by - tall, 0xe8e8e8)
205 cv.line(ax, ay - tall, bx, by - tall, 0xe03030)
206 if (big) {
207 cv.line(ax, ay - tall + 1, bx, by - tall + 1, 0xf0d020)
208 cv.line(ax, ay - tall - 1, bx, by - tall - 1, 0xe03030)
209 }
210 })
211
212 for (const r of w.rides) drawRide(w, r, v, cv, add, big)
213
214 for (const g of w.guests) {
215 if (g.state === 'ride') continue
216 let gx = g.x
217 let gy = g.y
218 if (g.state === 'queue') {
219 const r = w.rides.find(one => one.id === g.rideId)
220 if (r) {
221 const k = r.queue.indexOf(g.id)
222 const q = queueTile(r)
223 gx = q.x + 0.15 + (k % 5) * 0.17
224 gy = q.y + 0.15 + Math.floor(k / 5) * 0.16
225 }
226 }
227 add(gx + gy, () => {
228 const bob = g.state === 'queue' ? 0 : (w.frame + g.id) & 1
229 const [x, y] = proj(v, gx, gy, 0)
230 if (big) {
231 cv.set(x, y, bob ? 0x303040 : 0x404058)
232 cv.set(x, y - 1, g.shirt)
233 cv.set(x, y - 2, 0xf0c8a0)
234 } else {
235 cv.set(x, y, g.shirt)
236 }
237 })
238 }
239
240 for (const p of w.particles) {
241 add(p.x + p.y + 0.2, () => {
242 const [x, y] = proj(v, p.x, p.y, p.h)
243 cv.set(x, y, p.life < 0.4 ? 0x606060 : p.c)
244 if (big) cv.set(x + 1, y, p.life < 0.4 ? 0x505050 : p.c)
245 })
246 }
247
248 sprites.sort((a, b) => a.d - b.d)
249 for (const s of sprites) s.draw()
250
251 return cv.px
252}
253
254// A flat ride in a few pixels: its base, and a few dots that turn with it.
255function drawFlat(w: World, r: Ride, v: View, cv: Canvas, add: (d: number, f: () => void) => void) {
256 const frac = r.status === 'building' ? r.built / r.size : 1
257 const phase = r.train?.mode === 'run' ? r.train.s * 2.4 : 0
258 add(r.cx + r.cy, () => {
259 const [x, y] = proj(v, r.cx, r.cy, 0)
260 const rad = Math.max(1.5, v.a * 0.7)
261 cv.disc(x, y, rad * 0.6, r.status === 'building' ? 0x8c6a3c : shade(r.color, 0.6))
262 if (frac < 1) {
263 cv.line(x, y, x, y - Math.round(v.hs * 2 * frac), 0xc0c0c0)
264 if (w.frame & 2) cv.set(x, y - Math.round(v.hs * 2 * frac) - 1, 0xffe040)
265 return
266 }
267 const tall = r.kind === 'Observation Tower' ? 5 : r.kind === 'Ferris Wheel' || r.kind === 'Spiral Slide' ? 2.6 : 1.2
268 cv.line(x, y, x, y - Math.round(v.hs * tall), 0xd0d0d0)
269 for (let k = 0; k < 4; k++) {
270 const t = phase + (k * Math.PI) / 2
271 const px = Math.round(x + Math.cos(t) * rad)
272 const py = r.kind === 'Ferris Wheel'
273 ? Math.round(y - v.hs * 1.4 + Math.sin(t) * rad)
274 : Math.round(y - v.hs * 0.6 + Math.sin(t) * rad * 0.5)
275 cv.set(px, py, k % 2 ? r.color : r.carColor)
276 }
277 if (r.status === 'crashed' && w.frame & 2) cv.set(x, y - Math.round(v.hs * tall) - 2, 0x808080)
278 })
279}
280
281function drawRide(w: World, r: Ride, v: View, cv: Canvas, add: (d: number, f: () => void) => void, big: boolean) {
282 if (r.cat === 'flat') {
283 drawFlat(w, r, v, cv, add)
284 return
285 }
286 const n = r.nodes.length
287 const built = Math.min(r.built, n)
288 const pts = r.nodes.map(node => proj(v, node.x, node.y, node.h))
289 const dark = shade(r.color, 0.65)
290
291 for (let i = 0; i < built; i++) {
292 const node = r.nodes[i]!
293 if (node.isLoop || node.h < 0.25) continue
294 if (i % (big ? (r.isWood ? 1 : 2) : r.isWood ? 2 : 3) !== 0) continue
295 add(node.x + node.y - 0.03, () => {
296 const [x, y] = pts[i]!
297 const [, gy] = proj(v, node.x, node.y, 0)
298 cv.line(x, y + 1, x, gy, r.support)
299 if (r.isWood && big && i % 2 === 0) cv.line(x - 1, gy, x + 1, gy, shade(r.support, 0.8))
300 })
301 }
302
303 for (let i = 0; i < r.stationLen && i < built; i++) {
304 const node = r.nodes[i]!
305 add(node.x + node.y - 0.02, () => {
306 const [x, y] = pts[i]!
307 const half = Math.max(1, Math.round(v.a * 0.45))
308 cv.line(x - half, y + 1, x + half, y + 1, 0xd8d8d8)
309 if (big) cv.line(x - half, y + 2, x + half, y + 2, 0xb0b0b0)
310 })
311 }
312
313 const segs = built >= n ? n : built - 1
314 for (let i = 0; i < segs; i++) {
315 const a = r.nodes[i]!
316 const b = r.nodes[(i + 1) % n]!
317 add((a.x + b.x + a.y + b.y) / 2 + 0.05, () => {
318 const [x0, y0] = pts[i]!
319 const [x1, y1] = pts[(i + 1) % n]!
320 if (big) cv.line(x0, y0 + 1, x1, y1 + 1, dark)
321 cv.line(x0, y0, x1, y1, r.color)
322 })
323 }
324
325 if (r.status === 'building' && built > 0) {
326 const end = r.nodes[built - 1]!
327 add(end.x + end.y + 0.3, () => {
328 const [x, y] = pts[built - 1]!
329 if (w.frame & 2) {
330 cv.set(x, y - 1, 0xffe040)
331 cv.set(x, y - 2, 0xffe040)
332 }
333 const [gx, gy] = proj(v, end.x + 0.3, end.y + 0.3, 0)
334 cv.set(gx, gy, 0x2858e0)
335 cv.set(gx, gy - 1, 0xffd000)
336 })
337 }
338
339 if (r.status === 'crashed') {
340 const at = r.nodes[Math.min(n - 1, r.crashAt)]!
341 add(at.x + at.y + 0.4, () => {
342 const [x, y] = proj(v, at.x, at.y, at.h)
343 for (let k = 0; k < 5; k++) {
344 const rise = (w.frame * 0.5 + k * 3) % 12
345 const sx = x + Math.round(Math.sin(w.frame * 0.3 + k * 2) * 1.5)
346 cv.set(sx, y - Math.round(rise), rise > 8 ? 0x505050 : 0x808080)
347 }
348 const [gx, gy] = proj(v, at.x + 0.2, at.y + 0.1, 0)
349 cv.set(gx, gy, 0x404040)
350 cv.set(gx + 1, gy, r.carColor)
351 })
352 }
353
354 const t = r.train
355 if (!t) return
356 const cars = 4
357 for (let k = 0; k < cars; k++) {
358 const s = t.s - k * 0.9
359 if (s < 0 && t.isTest) continue
360 const p = nodeAt(r, s < 0 ? s + n : s)
361 add(p.x + p.y + 0.08, () => {
362 const [x, y] = proj(v, p.x, p.y, p.h)
363 const color = t.isTest && k === 0 ? 0xffffff : r.carColor
364 cv.set(x, y - 1, color)
365 cv.set(x + 1, y - 1, color)
366 if (big) {
367 cv.set(x - 1, y - 1, color)
368 cv.set(x - 1, y - 2, shade(color, 0.8))
369 cv.set(x + 1, y - 2, shade(color, 0.8))
370 }
371 for (let seat = 0; seat < 2; seat++) {
372 const id = t.riders[k * 2 + seat]
373 if (id === undefined) continue
374 const g = w.guests.find(one => one.id === id)
375 const sx = x + seat
376 cv.set(sx, y - 2, g?.shirt ?? 0xe02828)
377 if (big) cv.set(sx, y - 3, 0xf0c8a0)
378 }
379 })
380 }
381}
382
383export function toRasterCells(px: Uint32Array, W: number, rows: number): string {
384 const words = new Uint32Array(W * rows * 3)
385 for (let r = 0; r < rows; r++) {
386 for (let c = 0; c < W; c++) {
387 const top = px[2 * r * W + c] ?? BG
388 const bottom = px[(2 * r + 1) * W + c] ?? BG
389 const i = (r * W + c) * 3
390 words[i] = 0x2580
391 words[i + 1] = top
392 words[i + 2] = bottom
393 }
394 }
395
396 return base64(new Uint8Array(words.buffer))
397}
398
399function base64(bytes: Uint8Array): string {
400 const native = (bytes as unknown as { toBase64?: () => string }).toBase64
401 if (typeof native === 'function') return native.call(bytes)
402 let bin = ''
403 for (let i = 0; i < bytes.length; i += 0x8000) {
404 bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
405 }
406
407 return btoa(bin)
408}
409
410export function toSvg(px: Uint32Array, W: number, H: number, scale: number): string {
411 const runs = new Map<number, string[]>()
412 for (let y = 0; y < H; y++) {
413 let x = 0
414 while (x < W) {
415 const c = px[y * W + x]!
416 let end = x + 1
417 while (end < W && px[y * W + end] === c) end++
418 if (c !== BG) {
419 let list = runs.get(c)
420 if (!list) runs.set(c, (list = []))
421 list.push(`M${x} ${y}h${end - x}v1h${x - end}z`)
422 }
423 x = end
424 }
425 }
426 const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
427 let body = ''
428 for (const [c, list] of runs) body += `<path fill="${hex(c)}" d="${list.join('')}"/>`
429
430 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * scale}" height="${H * scale}" shape-rendering="crispEdges"><rect width="${W}" height="${H}" fill="${hex(BG)}"/>${body}</svg>`
431}
432hooks/vector.ts 497 lines1// The desktop's park: the same isometric scene as draw.ts, drawn as vector
2// shapes instead of pixels, so it stays sharp at whatever size the pane is.
3
4import { shade } from './draw'
5import { BLVD, GY, footprint, gridW, nodeAt, queueTile, slotAt } from './sim'
6import type { Ride, World } from './sim'
7
8const A = 20
9const B = A / 2
10const HS = A * 0.62
11const PAD = 12
12const CLIFF = 16
13const OX = GY * A + PAD
14const OY = 7 * HS + PAD
15
16// The drawing's size grows with the park.
17export function viewSize(w: World): { width: number; height: number } {
18 const gx = gridW(w)
19 return { width: (gx + GY) * A + PAD * 2, height: OY + (gx + GY) * B + CLIFF + PAD }
20}
21
22const P = (x: number, y: number, h: number): [number, number] => [
23 OX + (x - y) * A,
24 OY + (x + y) * B - h * HS,
25]
26const n1 = (v: number) => Math.round(v * 10) / 10
27const pt = ([x, y]: [number, number]) => `${n1(x)} ${n1(y)}`
28const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
29
30const GRASS = [0x5aa02e, 0x4f9428]
31const DIRT = [0x9a7444, 0x8c6a3c]
32const PATH = [0xa8a8a8, 0x9c9c9c]
33const QUEUE = [0xc09868, 0xb48c5c]
34const SKIN = '#f0c8a0'
35
36const peep = (step: number) =>
37 `<g id="g${step}"><path d="M-.8 0l${step * 0.8} -3M.8 0l${-step * 0.8} -3" stroke="#30304a" stroke-width="1.1"/><rect x="-1.7" y="-6.5" width="3.4" height="4" rx=".8"/><circle cy="-8" r="1.6" fill="${SKIN}"/></g>`
38// A 2×2 tile checker in ground coordinates, with faint tile edges.
39const checker = (id: string, [even, odd]: number[]) =>
40 `<pattern id="${id}" width="2" height="2" patternUnits="userSpaceOnUse"><rect width="2" height="2" fill="${hex(odd!)}"/>` +
41 `<path d="M0 0h1v1H0zM1 1h1v1H1z" fill="${hex(even!)}"/><path d="M0 0h2M0 1h2M0 0v2M1 0v2" stroke="#000" stroke-opacity=".12" stroke-width=".04"/></pattern>`
42const DEFS =
43 checker('pg', GRASS) + checker('pp', PATH) + checker('pq', QUEUE) + checker('pd', DIRT) +
44 '<g id="t"><ellipse rx="6" ry="3" fill="#000" opacity=".18"/><rect x="-1.2" y="-9" width="2.4" height="9" fill="#6a4424"/>' +
45 '<circle cy="-14" r="7" fill="#1f5a24"/><circle cx="-2" cy="-16" r="4" fill="#2f7a30"/><circle cx="-3" cy="-17.5" r="1.6" fill="#4a9a40"/></g>' +
46 peep(0) + peep(1)
47
48type Shape = { d: number; svg: string }
49
50function line(a: [number, number], b: [number, number], color: string, width: number, cap = ''): string {
51 return `<path d="M${pt(a)}L${pt(b)}" stroke="${color}" stroke-width="${width}"${cap ? ` stroke-linecap="${cap}"` : ''}/>`
52}
53
54// `maxCols`: past this many columns the drawing shows a window that wide,
55// centred on the camera, instead of shrinking the whole park to fit.
56export function renderSvg(w: World, maxCols = Infinity): string {
57 const out: string[] = []
58 const shapes: Shape[] = []
59 const add = (d: number, svg: string) => shapes.push({ d, svg })
60 const blink = (w.frame & 2) !== 0
61
62 // The ground and the cliff under its front edges.
63 const GX = gridW(w)
64 const left = P(0, GY, 0)
65 const bottom = P(GX, GY, 0)
66 const right = P(GX, 0, 0)
67 const down = (p: [number, number]): [number, number] => [p[0], p[1] + CLIFF]
68 out.push(`<polygon points="${pt(left)} ${pt(bottom)} ${pt(down(bottom))} ${pt(down(left))}" fill="#6e5028"/>`)
69 out.push(`<polygon points="${pt(bottom)} ${pt(right)} ${pt(down(right))} ${pt(down(bottom))}" fill="#54391c"/>`)
70 out.push(line([left[0], left[1] + CLIFF * 0.45], [bottom[0], bottom[1] + CLIFF * 0.45], '#5e4424', 1))
71 out.push(line([bottom[0], bottom[1] + CLIFF * 0.45], [right[0], right[1] + CLIFF * 0.45], '#46301a', 1))
72 // The tiles, drawn in ground coordinates (one unit per tile) through the
73 // projection as a transform: a few patterned shapes, however big the park.
74 let ground = `<rect width="${GX}" height="${GY}" fill="url(#pg)"/><rect y="${BLVD}" width="${GX}" height="1" fill="url(#pp)"/>`
75 for (const r of w.rides) {
76 const q = queueTile(r)
77 ground += `<rect x="${q.x}" y="${q.y}" width="1" height="1" fill="url(#pq)"/>`
78 if (r.status === 'building') {
79 const f = footprint(r)
80 ground += `<rect x="${f.x0}" y="${f.y0}" width="${f.w}" height="${f.h}" fill="url(#pd)"/>`
81 }
82 }
83 out.push(`<g transform="matrix(${A} ${B} ${-A} ${B} ${OX} ${OY})">${ground}</g>`)
84
85 for (const f of w.flowers) {
86 const [x, y] = P(f.x, f.y, 0)
87 add(f.x + f.y - 0.5, `<circle cx="${n1(x)}" cy="${n1(y - 1)}" r="1.6" fill="${hex(f.c)}"/>`)
88 }
89
90 for (const t of w.trees) {
91 const [x, y] = P(t.x, t.y, 0)
92 add(t.x + t.y, `<use href="#t" x="${n1(x)}" y="${n1(y)}"/>`)
93 }
94
95 // The park entrance: two striped posts and a banner.
96 {
97 const a = P(0.08, BLVD + 0.05, 0)
98 const b = P(0.08, BLVD + 0.95, 0)
99 const tall = HS * 2.6
100 const top = (p: [number, number]): [number, number] => [p[0], p[1] - tall]
101 add(BLVD + 0.2,
102 line(a, top(a), '#eeeeee', 3) + line(b, top(b), '#eeeeee', 3) +
103 `<polygon points="${pt(top(a))} ${pt(top(b))} ${pt([b[0], b[1] - tall + 9])} ${pt([a[0], a[1] - tall + 9])}" fill="#d82828" stroke="#f0d020" stroke-width="1.2"/>`)
104 }
105
106 for (const r of w.rides) drawRide(w, r, add, blink)
107
108 for (const g of w.guests) {
109 if (g.state === 'ride') continue
110 let gx = g.x
111 let gy = g.y
112 if (g.state === 'queue') {
113 const r = w.rides.find(one => one.id === g.rideId)
114 if (r) {
115 const k = r.queue.indexOf(g.id)
116 const q = queueTile(r)
117 gx = q.x + 0.15 + (k % 5) * 0.17
118 gy = q.y + 0.15 + Math.floor(k / 5) * 0.16
119 }
120 }
121 const [x, y] = P(gx, gy, 0)
122 const step = g.state === 'queue' ? 0 : (w.frame + g.id) & 1
123 add(gx + gy, `<use href="#g${step}" x="${n1(x)}" y="${n1(y)}" fill="${hex(g.shirt)}"/>`)
124 }
125
126 for (const p of w.particles) {
127 const [x, y] = P(p.x, p.y, p.h)
128 const isSmoke = p.life < 0.4
129 add(p.x + p.y + 0.2, `<circle cx="${n1(x)}" cy="${n1(y)}" r="${isSmoke ? 3 : 2}" fill="${isSmoke ? '#707070' : hex(p.c)}"${isSmoke ? ' opacity=".6"' : ''}/>`)
130 }
131
132 shapes.sort((a, b) => a.d - b.d)
133 for (const s of shapes) out.push(s.svg)
134
135 const { width: full, height } = viewSize(w)
136 const width = w.cols > maxCols ? viewSize({ ...w, cols: maxCols }).width : full
137 const x0 = Math.min(full - width, Math.max(0, P(w.camX, w.camY, 0)[0] - width / 2))
138 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n1(x0)} 0 ${n1(width)} ${n1(height)}" width="${Math.round(width * 2)}" height="${Math.round(height * 2)}" stroke-linecap="round" stroke-linejoin="round"><defs>${DEFS}</defs><rect x="${n1(x0)}" width="${n1(width)}" height="${n1(height)}" fill="#0e1210"/>${out.join('')}</svg>`
139}
140
141function drawRide(w: World, r: Ride, add: (d: number, svg: string) => void, blink: boolean) {
142 if (r.cat === 'flat') {
143 drawFlat(w, r, add, blink)
144 return
145 }
146 const n = r.nodes.length
147 const built = Math.min(r.built, n)
148 const pts = r.nodes.map(node => P(node.x, node.y, node.h))
149 const rail = hex(r.color)
150 const under = hex(shade(r.color, 0.55))
151 const support = hex(r.support)
152 const brace = hex(shade(r.support, 0.75))
153 // The whole ride is one sprite, sorted by its plot: behind the boulevard
154 // when it lies beyond it, in front when it lies before it.
155 const f = footprint(r)
156 const depth = slotAt(r.slot).isTop ? f.x0 + f.w + f.y0 + f.h : f.x0 + f.y0
157
158 let posts = ''
159 let braces = ''
160 let plates = ''
161 for (let i = 0; i < built; i++) {
162 const node = r.nodes[i]!
163 if (node.isLoop || node.h < 0.25) continue
164 if (!r.isWood && i % 2 === 1) continue
165 const top = pts[i]!
166 const foot = P(node.x, node.y, 0)
167 posts += `M${n1(top[0])} ${n1(top[1] + 2)}V${n1(foot[1])}`
168 if (r.isWood && node.h > 1.2) {
169 const mid = n1((top[1] + foot[1]) / 2)
170 braces += `M${n1(foot[0] - 3)} ${n1(foot[1])}L${n1(foot[0])} ${mid}L${n1(foot[0] + 3)} ${n1(foot[1])}`
171 }
172 if (!r.isWood) plates += `M${n1(foot[0] - 1.5)} ${n1(foot[1] - 1)}h3v2h-3z`
173 }
174 let svg = (posts ? `<path d="${posts}" stroke="${support}" stroke-width="${r.isWood ? 1.6 : 1.3}"/>` : '') +
175 (braces ? `<path d="${braces}" fill="none" stroke="${brace}" stroke-width="1"/>` : '') +
176 (plates ? `<path d="${plates}" fill="#808088"/>` : '')
177
178 for (let i = 0; i < r.stationLen && i < built; i++) {
179 const [x, y] = pts[i]!
180 svg += `<rect x="${n1(x - 8)}" y="${n1(y + 1)}" width="16" height="4" fill="#d8d8d8" stroke="#a0a0a0" stroke-width=".6"/>` +
181 (i % 2 === 0 ? `<rect x="${n1(x - 8)}" y="${n1(y - 13)}" width="16" height="3" fill="${rail}"/>${line([x - 7, y + 1], [x - 7, y - 10], '#c0c0c0', 1)}` : '')
182 }
183
184 const ends = built >= n ? [...pts, pts[0]!] : pts.slice(0, built)
185 if (ends.length > 1) {
186 const d = (dy: number) => 'M' + ends.map(([x, y]) => `${n1(x)} ${n1(y + dy)}`).join('L')
187 svg += r.kind === 'Log Flume'
188 ? `<path d="${d(1)}" fill="none" stroke="#6a4424" stroke-width="7"/><path d="${d(0)}" fill="none" stroke="#5ab4f4" stroke-width="3.6"/>`
189 : `<path d="${d(1.6)}" fill="none" stroke="${under}" stroke-width="2.4"/><path d="${d(0)}" fill="none" stroke="${rail}" stroke-width="2.6"/>`
190 }
191 add(depth, svg)
192
193 if (r.status === 'building' && built > 0) {
194 const end = r.nodes[built - 1]!
195 const [x, y] = pts[built - 1]!
196 const [gx, gy] = P(end.x + 0.3, end.y + 0.3, 0)
197 add(depth + 0.02,
198 (blink ? `<circle cx="${n1(x)}" cy="${n1(y - 3)}" r="3" fill="#ffe040" stroke="#a08000" stroke-width=".8"/>` : '') +
199 `<rect x="${n1(gx - 1.7)}" y="${n1(gy - 6.5)}" width="3.4" height="4" fill="#2858e0"/>` +
200 `<circle cx="${n1(gx)}" cy="${n1(gy - 8)}" r="1.6" fill="${SKIN}"/>` +
201 `<path d="M${n1(gx - 2.2)} ${n1(gy - 8.6)}h4.4" stroke="#ffd000" stroke-width="1.6"/>`)
202 }
203
204 if (r.status === 'crashed') {
205 const at = r.nodes[Math.min(n - 1, r.crashAt)]!
206 const [x, y] = P(at.x, at.y, at.h)
207 const [gx, gy] = P(at.x + 0.25, at.y + 0.15, 0)
208 let svg = `<path d="M${n1(gx - 5)} ${n1(gy)}l4 -3l5 1l3 2z" fill="#404040"/><rect x="${n1(gx - 2)}" y="${n1(gy - 4)}" width="6" height="3" fill="${hex(r.carColor)}" transform="rotate(25 ${n1(gx)} ${n1(gy)})"/>`
209 for (let k = 0; k < 5; k++) {
210 const rise = (w.frame * 0.8 + k * 6) % 30
211 const sx = x + Math.sin(w.frame * 0.25 + k * 2) * 3
212 svg += `<circle cx="${n1(sx)}" cy="${n1(y - rise)}" r="${n1(2 + rise / 8)}" fill="#808080" opacity="${n1(0.7 - rise / 50)}"/>`
213 }
214 add(depth + 0.02, svg)
215 }
216
217 const t = r.train
218 if (!t) return
219 const car = hex(t.isTest ? 0xffffff : r.carColor)
220 const carDark = hex(shade(t.isTest ? 0xdddddd : r.carColor, 0.7))
221 for (let k = 0; k < 4; k++) {
222 const s = t.s - k * 0.9
223 if (s < 0 && t.isTest) continue
224 const at = s < 0 ? s + n : s
225 const p = nodeAt(r, at)
226 const front = P(...xyh(nodeAt(r, at + 0.3)))
227 const back = P(...xyh(nodeAt(r, at - 0.3)))
228 let svg = line([back[0], back[1] - 2], [front[0], front[1] - 2], carDark, 7, 'round') + line([back[0], back[1] - 3], [front[0], front[1] - 3], car, 5, 'round')
229 const [cx, cy] = P(p.x, p.y, p.h)
230 for (let seat = 0; seat < 2; seat++) {
231 const id = t.riders[k * 2 + seat]
232 if (id === undefined) continue
233 const g = w.guests.find(one => one.id === id)
234 const sx = cx + (seat === 0 ? -1.8 : 1.8)
235 svg += `<rect x="${n1(sx - 1.4)}" y="${n1(cy - 8)}" width="2.8" height="3" fill="${hex(g?.shirt ?? 0xe02828)}"/><circle cx="${n1(sx)}" cy="${n1(cy - 9.5)}" r="1.5" fill="${SKIN}"/>`
236 }
237 add(depth + 0.01, svg)
238 }
239}
240
241const xyh = (p: { x: number; y: number; h: number }): [number, number, number] => [p.x, p.y, p.h]
242
243// ---- Flat rides ------------------------------------------------------------
244// Each is drawn around its centre (cx, cy) on the ground; `phase` turns while
245// a cycle runs, and the riders sit on whatever moves.
246
247type Ctx = { r: Ride; phase: number; swing: number; riders: string[] }
248
249const ring = (cx: number, cy: number, rad: number, h: number, k: number, from = 0): [number, number][] =>
250 Array.from({ length: k }, (_, i) => {
251 const t = from + (i / k) * Math.PI * 2
252 return P(cx + Math.cos(t) * rad, cy + Math.sin(t) * rad, h)
253 })
254
255const poly = (pts: [number, number][], fill: string, extra = '') => `<polygon points="${pts.map(pt).join(' ')}" fill="${fill}"${extra}/>`
256const ellipse = (c: [number, number], rad: number, fill: string, extra = '') =>
257 `<ellipse cx="${n1(c[0])}" cy="${n1(c[1])}" rx="${n1(rad * A * 1.414)}" ry="${n1(rad * B * 1.414)}" fill="${fill}"${extra}/>`
258const rider = (x: number, y: number, shirt: string) =>
259 `<rect x="${n1(x - 1.3)}" y="${n1(y - 3)}" width="2.6" height="2.6" fill="${shirt}"/><circle cx="${n1(x)}" cy="${n1(y - 4.3)}" r="1.4" fill="${SKIN}"/>`
260
261function drawFlat(w: World, r: Ride, add: (d: number, svg: string) => void, blink: boolean) {
262 const t = r.train
263 const running = t?.mode === 'run'
264 const elapsed = t?.elapsed ?? 0
265 const ramp = running ? Math.min(1, elapsed / 1.5, Math.max(0, (7 - elapsed) / 1.5)) : 0
266 const riders = (t?.riders ?? []).map(id => hex(w.guests.find(g => g.id === id)?.shirt ?? 0xe02828))
267 const ctx: Ctx = { r, phase: (t?.s ?? 0) * 2.2 * (0.3 + ramp * 0.7), swing: ramp, riders }
268 const d = r.cx + r.cy
269 const base = ellipse(P(r.cx, r.cy, 0), 0.95, '#000', ' opacity=".15"')
270 if (r.status === 'building') {
271 const frac = r.built / r.size
272 const top = P(r.cx, r.cy, 3 * frac)
273 const foot = P(r.cx, r.cy, 0)
274 add(d, base +
275 `<g opacity="${n1(0.25 + frac * 0.55)}">${drawKind(ctx)}</g>` +
276 line([foot[0] - 7, foot[1]], [top[0] - 7, top[1]], '#c8c8c8', 1) + line([foot[0] + 7, foot[1]], [top[0] + 7, top[1]], '#c8c8c8', 1) +
277 line([foot[0] - 7, top[1]], [foot[0] + 7, top[1]], '#c8c8c8', 1) +
278 (blink ? `<circle cx="${n1(top[0])}" cy="${n1(top[1] - 3)}" r="3" fill="#ffe040" stroke="#a08000" stroke-width=".8"/>` : '') +
279 `<use href="#g0" x="${n1(foot[0] + 10)}" y="${n1(foot[1] + 4)}" fill="#2858e0"/>`)
280 return
281 }
282 let svg = base + drawKind(ctx)
283 if (r.status === 'crashed') {
284 const [x, y] = P(r.cx, r.cy, 1.5)
285 for (let k = 0; k < 5; k++) {
286 const rise = (w.frame * 0.8 + k * 6) % 30
287 svg += `<circle cx="${n1(x + Math.sin(w.frame * 0.25 + k * 2) * 3)}" cy="${n1(y - rise)}" r="${n1(2 + rise / 8)}" fill="#808080" opacity="${n1(0.7 - rise / 50)}"/>`
288 }
289 }
290 add(d, svg)
291}
292
293function drawKind(c: Ctx): string {
294 switch (c.r.kind) {
295 case 'Merry-Go-Round': return carousel(c)
296 case 'Spiral Slide': return slide(c)
297 case 'Dodgems': return dodgems(c)
298 case 'Twist': return twist(c)
299 case 'Ferris Wheel': return ferris(c)
300 case 'Swinging Ship': return ship(c)
301 default: return tower(c)
302 }
303}
304
305function carousel({ r, phase, riders }: Ctx): string {
306 const { cx, cy } = r
307 let svg = ellipse(P(cx, cy, 0.15), 0.85, '#b89058') + ellipse(P(cx, cy, 0.3), 0.85, '#e8d8b0')
308 const horses: { y: number; svg: string }[] = []
309 for (let k = 0; k < 8; k++) {
310 const t = phase + (k * Math.PI) / 4
311 const bob = Math.sin(phase * 2 + k) * 0.12
312 const foot = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 0.3)
313 const body = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 0.9 + bob)
314 const top = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 1.7)
315 horses.push({
316 y: body[1],
317 svg: line(foot, top, '#e0c040', 0.8) +
318 `<rect x="${n1(body[0] - 3)}" y="${n1(body[1] - 2)}" width="6" height="3" rx="1.2" fill="${k % 2 ? '#ffffff' : '#c08040'}"/>` +
319 (riders[k] ? rider(body[0], body[1] - 1.5, riders[k]!) : ''),
320 })
321 }
322 horses.sort((a, b) => a.y - b.y)
323 const back = horses.filter(h => h.y < P(cx, cy, 0.9)[1])
324 const front = horses.filter(h => h.y >= P(cx, cy, 0.9)[1])
325 svg += back.map(h => h.svg).join('') + line(P(cx, cy, 0.3), P(cx, cy, 2.3), '#e0c040', 2) + front.map(h => h.svg).join('')
326 const rim = ring(cx, cy, 0.9, 1.7, 12, phase * 0.2)
327 const apex = P(cx, cy, 2.5)
328 const wedges = rim.map((p, i) => ({ y: (p[1] + rim[(i + 1) % 12]![1]) / 2, svg: poly([apex, p, rim[(i + 1) % 12]!], i % 2 ? '#ffffff' : hex(r.color), ' stroke="#00000022" stroke-width=".4"') }))
329 wedges.sort((a, b) => a.y - b.y)
330 svg += wedges.map(x => x.svg).join('')
331 svg += `<circle cx="${n1(apex[0])}" cy="${n1(apex[1] - 1.5)}" r="1.6" fill="#f0d020"/>`
332
333 return svg
334}
335
336function slide({ r, phase, riders }: Ctx): string {
337 const { cx, cy } = r
338 const tall = 3.4
339 const w = 0.32
340 const a = P(cx - w, cy + w, 0)
341 const b = P(cx + w, cy + w, 0)
342 const c2 = P(cx + w, cy - w, 0)
343 const lift = (p: [number, number]): [number, number] => [p[0], p[1] - tall * HS]
344 let back = ''
345 let front = ''
346 const turns = 2.6
347 const pts: [number, number][] = []
348 for (let k = 0; k <= 40; k++) {
349 const t = (k / 40) * Math.PI * 2 * turns
350 pts.push(P(cx + Math.cos(t) * 0.75, cy + Math.sin(t) * 0.75, tall - (k / 40) * (tall - 0.2)))
351 }
352 for (let k = 0; k < 40; k++) {
353 const t = (k / 40) * Math.PI * 2 * turns
354 const seg = line(pts[k]!, pts[k + 1]!, '#f0c020', 3.2) + line([pts[k]![0], pts[k]![1] + 1.2], [pts[k + 1]![0], pts[k + 1]![1] + 1.2], '#b08010', 1)
355 if (Math.sin(t) < 0) back += seg
356 else front += seg
357 }
358 const tower = poly([a, b, lift(b), lift(a)], hex(r.color)) + poly([b, c2, lift(c2), lift(b)], hex(shade(r.color, 0.7))) +
359 poly([lift(a), lift(b), [lift(b)[0], lift(b)[1] - 8], [(lift(a)[0] + lift(b)[0]) / 2, lift(a)[1] - 14]], '#d82828')
360 let sliders = ''
361 riders.slice(0, 3).forEach((shirt, i) => {
362 const k = Math.floor(((phase * 4 + i * 13) % 40 + 40) % 40)
363 sliders += rider(pts[k]![0], pts[k]![1] + 1, shirt)
364 })
365
366 return back + tower + front + sliders
367}
368
369function dodgems({ r, phase, riders }: Ctx): string {
370 const { cx, cy } = r
371 const s = 0.85
372 const floor = [P(cx - s, cy - s, 0), P(cx + s, cy - s, 0), P(cx + s, cy + s, 0), P(cx - s, cy + s, 0)]
373 let svg = poly(floor, '#4a4a58', ' stroke="#e8c020" stroke-width="1.6"')
374 const cars = ['#e02828', '#2858e0', '#28b048', '#f0d028', '#b030c8', '#18c0c0']
375 const placed = cars.map((color, i) => {
376 const x = cx + Math.sin(phase * (0.7 + i * 0.13) + i * 1.7) * 0.6
377 const y = cy + Math.cos(phase * (0.6 + i * 0.11) + i * 2.3) * 0.6
378 return { y: x + y, p: P(x, y, 0), color, shirt: riders[i] }
379 })
380 placed.sort((a, b) => a.y - b.y)
381 for (const car of placed) {
382 svg += `<rect x="${n1(car.p[0] - 3.5)}" y="${n1(car.p[1] - 3)}" width="7" height="4" rx="1.5" fill="${car.color}" stroke="#202020" stroke-width=".6"/>`
383 svg += line([car.p[0] + 2, car.p[1] - 3], [car.p[0] + 2, car.p[1] - 16], '#909090', 0.5)
384 if (car.shirt) svg += rider(car.p[0] - 0.5, car.p[1] - 2, car.shirt)
385 }
386 const roof = [P(cx - s, cy - s, 1.4), P(cx + s, cy - s, 1.4), P(cx + s, cy + s, 1.4), P(cx - s, cy + s, 1.4)]
387 for (let i = 0; i < 4; i++) svg += line(floor[i]!, roof[i]!, '#c0c0c0', 1.2)
388 svg += poly(roof, hex(r.color), ' opacity=".35" stroke="#ffffff" stroke-width="1"')
389
390 return svg
391}
392
393function twist({ r, phase, riders }: Ctx): string {
394 const { cx, cy } = r
395 let svg = ellipse(P(cx, cy, 0.1), 0.9, '#707080') + ellipse(P(cx, cy, 0.2), 0.85, '#9a9aaa')
396 const cars: { y: number; svg: string }[] = []
397 for (let arm = 0; arm < 3; arm++) {
398 const ta = phase * 0.8 + (arm * Math.PI * 2) / 3
399 const hx = cx + Math.cos(ta) * 0.5
400 const hy = cy + Math.sin(ta) * 0.5
401 svg += line(P(cx, cy, 0.8), P(hx, hy, 0.6), '#c8c8d0', 1.4)
402 for (let k = 0; k < 3; k++) {
403 const tc = -phase * 2.2 + (k * Math.PI * 2) / 3
404 const p = P(hx + Math.cos(tc) * 0.28, hy + Math.sin(tc) * 0.28, 0.45)
405 const shirt = riders[arm * 3 + k]
406 cars.push({ y: p[1], svg: `<ellipse cx="${n1(p[0])}" cy="${n1(p[1])}" rx="3.4" ry="2.2" fill="${hex(k % 2 ? r.color : r.carColor)}" stroke="#202020" stroke-width=".5"/>` + (shirt ? rider(p[0], p[1], shirt) : '') })
407 }
408 }
409 cars.sort((a, b) => a.y - b.y)
410 svg += line(P(cx, cy, 0.2), P(cx, cy, 1.1), '#e0c040', 2.4) + cars.map(c => c.svg).join('')
411
412 return svg
413}
414
415function ferris({ r, phase, riders }: Ctx): string {
416 const { cx, cy } = r
417 const rh = 2.1
418 const rt = 1.3
419 const hub = P(cx, cy, rh + 0.35)
420 const at = (t: number, k = 1): [number, number] => P(cx, cy + Math.cos(t) * rt * k, rh + 0.35 + Math.sin(t) * rh * k)
421 let svg = ''
422 for (const side of [-0.28, 0.28]) {
423 svg += line(P(cx + side, cy - 0.8, 0), P(cx + side * 0.3, cy, rh + 0.35), '#8a8a94', 1.8)
424 svg += line(P(cx + side, cy + 0.8, 0), P(cx + side * 0.3, cy, rh + 0.35), '#8a8a94', 1.8)
425 }
426 const rim = Array.from({ length: 28 }, (_, i) => at((i / 28) * Math.PI * 2))
427 svg += `<polygon points="${rim.map(pt).join(' ')}" fill="none" stroke="${hex(r.color)}" stroke-width="2"/>`
428 svg += `<polygon points="${Array.from({ length: 28 }, (_, i) => at((i / 28) * Math.PI * 2, 0.6)).map(pt).join(' ')}" fill="none" stroke="${hex(shade(r.color, 0.7))}" stroke-width="1"/>`
429 for (let k = 0; k < 8; k++) {
430 const t = -phase * 0.5 + (k * Math.PI) / 4
431 const p = at(t)
432 svg += line(hub, p, '#d0d0d8', 0.7)
433 }
434 for (let k = 0; k < 8; k++) {
435 const t = -phase * 0.5 + (k * Math.PI) / 4
436 const p = at(t)
437 const color = ['#e02828', '#2858e0', '#f0d028', '#28b048'][k % 4]!
438 svg += line(p, [p[0], p[1] + 3], '#606060', 0.8)
439 svg += `<rect x="${n1(p[0] - 3)}" y="${n1(p[1] + 3)}" width="6" height="4.5" rx="1" fill="${color}"/>`
440 if (riders[k]) svg += `<circle cx="${n1(p[0])}" cy="${n1(p[1] + 3.4)}" r="1.3" fill="${SKIN}"/>`
441 }
442 svg += `<circle cx="${n1(hub[0])}" cy="${n1(hub[1])}" r="2.2" fill="#f0d020"/>`
443
444 return svg
445}
446
447function ship({ r, phase, swing, riders }: Ctx): string {
448 const { cx, cy } = r
449 const ph = 2.9
450 const pivot = P(cx, cy, ph)
451 const theta = Math.sin(phase * 1.3) * 1.15 * swing
452 const len = 2.3
453 const toScreen = (along: number, up: number): [number, number] => {
454 const ca = Math.cos(theta)
455 const sa = Math.sin(theta)
456 const dy = along * ca - (up - len) * sa
457 const dh = along * sa + (up - len) * ca
458 return P(cx, cy + dy * 0.62, ph + dh)
459 }
460 let svg = ''
461 for (const side of [-0.45, 0.45]) {
462 svg += line(P(cx + side, cy - 1, 0), pivot, '#7a7a84', 2)
463 svg += line(P(cx + side, cy + 1, 0), pivot, '#7a7a84', 2)
464 }
465 svg += line(pivot, toScreen(-1, 0.4), '#a0a0a8', 1.4) + line(pivot, toScreen(1, 0.4), '#a0a0a8', 1.4)
466 const hull = [toScreen(-1.4, 0.55), toScreen(1.4, 0.55), toScreen(1.0, 0), toScreen(-1.0, 0)]
467 svg += poly(hull, '#8a5a2a', ' stroke="#5a3a1a" stroke-width="1"')
468 svg += poly([toScreen(-1.3, 0.55), toScreen(1.3, 0.55), toScreen(1.25, 0.42), toScreen(-1.25, 0.42)], hex(r.color))
469 riders.slice(0, 10).forEach((shirt, i) => {
470 const p = toScreen(-1.1 + (i / 9) * 2.2, 0.6)
471 svg += rider(p[0], p[1], shirt)
472 })
473 svg += `<circle cx="${n1(pivot[0])}" cy="${n1(pivot[1])}" r="2" fill="#f0d020"/>`
474
475 return svg
476}
477
478function tower({ r, phase, riders }: Ctx): string {
479 const { cx, cy } = r
480 const tall = 6
481 const lift = (1 - Math.cos(phase * 0.45)) / 2
482 const h = 0.4 + lift * (tall - 1.2)
483 let svg = ellipse(P(cx, cy, 0), 0.5, '#9a9aaa')
484 svg += line(P(cx, cy, 0), P(cx, cy, tall), '#b8b8c4', 4) + line(P(cx, cy, 0), P(cx, cy, tall), '#e0e0e8', 1.5)
485 svg += ellipse(P(cx, cy, h), 0.45, hex(shade(r.color, 0.7))) + ellipse(P(cx, cy, h + 0.25), 0.45, hex(r.color))
486 riders.slice(0, 6).forEach((shirt, i) => {
487 const t = (i / 6) * Math.PI * 2
488 if (Math.sin(t) < -0.2) return
489 const p = P(cx + Math.cos(t) * 0.38, cy + Math.sin(t) * 0.38, h + 0.25)
490 svg += rider(p[0], p[1], shirt)
491 })
492 const top = P(cx, cy, tall)
493 svg += `<circle cx="${n1(top[0])}" cy="${n1(top[1])}" r="3" fill="#d82828"/>`
494
495 return svg
496}
497hooks/sim.ts 1079 lines1// The park simulation: rides under construction, test runs, trains, guests.
2// Plain data and functions, no `$`: register.tsx drives it, draw.ts and
3// vector.ts paint it.
4//
5// Which model does the work picks the ride: a frontier model's crew builds
6// big coasters, Sonnet's builds family rides, Haiku's builds small flat rides.
7// Each model has its own crew, so a Haiku subagent builds beside Opus.
8
9// The park grows along its boulevard: each column is 4 tiles wide and holds
10// two plots, one either side. It opens with 4 columns and buys more land
11// whenever a crew runs out of room, up to COLS_MAX.
12export const COLS_START = 4
13export const COLS_MAX = 12
14export const GY = 13
15export const BLVD = 6
16
17export type Tier = 'big' | 'medium' | 'small'
18export type Node = { x: number; y: number; h: number; isLoop?: true }
19export type RideStatus = 'building' | 'testing' | 'open' | 'crashed'
20
21export type Train = {
22 s: number
23 mode: 'load' | 'run'
24 wait: number
25 elapsed: number
26 riders: number[]
27 isTest: boolean
28}
29
30export type Ride = {
31 id: number
32 name: string
33 kind: string
34 tier: Tier
35 cat: 'track' | 'flat'
36 builtBy: string
37 slot: number
38 half: number
39 color: number
40 carColor: number
41 support: number
42 isWood: boolean
43 cars: number
44 capacity: number
45 nodes: Node[]
46 built: number
47 size: number
48 stationLen: number
49 liftEnd: number
50 splashAt: number
51 cx: number
52 cy: number
53 qx: number
54 qy: number
55 status: RideStatus
56 excitement: number
57 intensity: number
58 nausea: number
59 price: number
60 riders: number
61 calls: number
62 fails: number
63 crashAt: number
64 train?: Train
65 queue: number[]
66 willCrash?: boolean
67}
68
69export type Guest = {
70 id: number
71 x: number
72 y: number
73 tx: number
74 state: 'walk' | 'toQueue' | 'queue' | 'ride' | 'leave'
75 rideId: number
76 rides: number
77 maxRides: number
78 shirt: number
79 speed: number
80 lastRide?: string
81}
82
83export type Particle = { x: number; y: number; h: number; vx: number; vy: number; vh: number; life: number; c: number }
84
85export type World = {
86 rides: Ride[]
87 guests: Guest[]
88 money: number
89 guestsTotal: number
90 nextGuest: number
91 rideCounter: number
92 trees: { x: number; y: number }[]
93 flowers: { x: number; y: number; c: number }[]
94 particles: Particle[]
95 news: string
96 newsAt: number
97 thought: string
98 time: number
99 frame: number
100 groundVer: number
101 spawnAcc: number
102 thoughtAcc: number
103 finishAcc: number
104 isFinishing: boolean
105 isWorking: boolean
106 toasts: string[]
107 milestone: number
108 isDirty: boolean
109 camX: number
110 camY: number
111 cols: number
112 isShared: boolean
113 shareName: string
114}
115
116type Slot = { x0: number; y0: number; w: number; h: number; isTop: boolean }
117
118// Plot i: column i / 2, above the boulevard when even, below it when odd.
119export function slotAt(i: number): Slot {
120 const isTop = i % 2 === 0
121 return { x0: 1 + 4 * Math.floor(i / 2), y0: isTop ? 0 : 8, w: 4, h: 5, isTop }
122}
123
124// The map's width in tiles: a border column at each end of the plots.
125export function gridW(w: { cols: number }): number {
126 return 2 + 4 * w.cols
127}
128
129// Math.random may repeat itself in the plugin's environment, so the park
130// rolls its own dice, seeded from the host's entropy.
131let seed = (() => {
132 try {
133 return crypto.getRandomValues(new Uint32Array(1))[0]! ^ Date.now()
134 } catch {
135 return Date.now() >>> 0
136 }
137})()
138
139export function rnd(): number {
140 seed = (seed + 0x6d2b79f5) | 0
141 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
142 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
143
144 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
145}
146
147const pick = <T>(list: readonly T[]): T => list[Math.floor(rnd() * list.length)]!
148const between = (lo: number, hi: number) => lo + rnd() * (hi - lo)
149const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
150
151type Layout = 'oval' | 'figure8' | 'outback' | 'helix' | 'twister' | 'zigzag'
152type Profile = 'coaster' | 'mouse' | 'flume'
153
154type TrackSpec = {
155 kind: string
156 isWood: boolean
157 hmax: [number, number]
158 loops: [number, number]
159 corkscrews: [number, number]
160 layouts: Layout[]
161 cars: number
162 profile: Profile
163 speed: number
164 base: number
165}
166
167const BIG: TrackSpec[] = [
168 { kind: 'Wooden Roller Coaster', isWood: true, hmax: [4.5, 6], loops: [0, 0], corkscrews: [0, 0], layouts: ['oval', 'figure8', 'outback', 'twister'], cars: 4, profile: 'coaster', speed: 1, base: 1.4 },
169 { kind: 'Steel Twister', isWood: false, hmax: [5, 7], loops: [1, 2], corkscrews: [1, 1], layouts: ['twister', 'helix', 'figure8'], cars: 4, profile: 'coaster', speed: 1.1, base: 1.2 },
170 { kind: 'Corkscrew Coaster', isWood: false, hmax: [4.5, 6], loops: [0, 1], corkscrews: [1, 2], layouts: ['twister', 'oval', 'helix'], cars: 4, profile: 'coaster', speed: 1, base: 1.2 },
171 { kind: 'Mine Train Coaster', isWood: true, hmax: [3, 4.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['twister', 'helix', 'zigzag'], cars: 5, profile: 'coaster', speed: 0.9, base: 1.6 },
172 { kind: 'Looping Coaster', isWood: false, hmax: [5.5, 7], loops: [2, 2], corkscrews: [0, 1], layouts: ['oval', 'figure8', 'twister'], cars: 4, profile: 'coaster', speed: 1.1, base: 1.1 },
173 { kind: 'Hyper Coaster', isWood: false, hmax: [7.5, 9.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['outback', 'oval', 'figure8', 'helix'], cars: 5, profile: 'coaster', speed: 1.2, base: 1.3 },
174]
175
176const MEDIUM_TRACK: TrackSpec[] = [
177 { kind: 'Wild Mouse', isWood: false, hmax: [2.6, 3.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['zigzag'], cars: 1, profile: 'mouse', speed: 0.8, base: 2.4 },
178 { kind: 'Junior Coaster', isWood: false, hmax: [1.8, 2.6], loops: [0, 0], corkscrews: [0, 0], layouts: ['oval', 'twister', 'figure8'], cars: 3, profile: 'coaster', speed: 0.85, base: 2.1 },
179 { kind: 'Log Flume', isWood: true, hmax: [2.4, 3.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['twister', 'oval', 'helix'], cars: 2, profile: 'flume', speed: 0.7, base: 2.6 },
180]
181
182type FlatSpec = { kind: string; e: number; i: number; n: number; capacity: number; size: number }
183
184const MEDIUM_FLAT: FlatSpec[] = [
185 { kind: 'Ferris Wheel', e: 2.3, i: 0.8, n: 0.5, capacity: 8, size: 16 },
186 { kind: 'Swinging Ship', e: 3.6, i: 4.1, n: 4.2, capacity: 12, size: 16 },
187 { kind: 'Observation Tower', e: 2.6, i: 0.7, n: 0.3, capacity: 8, size: 16 },
188]
189
190const SMALL_FLAT: FlatSpec[] = [
191 { kind: 'Merry-Go-Round', e: 1.4, i: 0.6, n: 0.4, capacity: 12, size: 10 },
192 { kind: 'Spiral Slide', e: 1.5, i: 1.4, n: 0.9, capacity: 4, size: 10 },
193 { kind: 'Dodgems', e: 2.4, i: 1.9, n: 0.7, capacity: 6, size: 10 },
194 { kind: 'Twist', e: 2.8, i: 4.1, n: 4.5, capacity: 9, size: 10 },
195]
196
197const NAMES: Record<Tier, string[]> = {
198 big: [
199 'Null Pointer Plunge', 'The Segfault', 'Merge Conflict', 'Stack Overflow',
200 'Race Condition', 'Infinite Loop', 'The Big O', 'Heap Drop', 'Recursion Rush',
201 'The Refactorer', 'Dependency Hell', 'Kernel Panic', 'Fork Bomb', 'Deadlock Drop',
202 'Undefined Behaviour', 'Production Push', 'The Monolith', 'Force Push',
203 ],
204 medium: [
205 'Off-By-One', 'Hotfix Express', 'Cache Miss', 'Git Blame', 'Callback Canyon',
206 'Tail Call', 'Rebase Rapids', 'Async Await', 'The Linter', 'Garbage Collector',
207 'Type Coercion', 'The Monad', 'Lazy Loader', 'Event Loop',
208 ],
209 small: [
210 'Little Lambda', 'Tiny Tuple', 'Hello World', 'The Semicolon', 'Bit Flip',
211 'Small Talk', 'The Null Check', 'Quick Sort', 'Byte Size', 'Tab Spinner',
212 'Pixel Pals', 'The Typo', 'Mini Map', 'Unit Test',
213 ],
214}
215
216const TRACK_COLORS = [0xe03030, 0xf0c020, 0x3a68e8, 0xe060c0, 0xf08020, 0xf4f4f4, 0x9a48e8, 0x20c8e8, 0x30c060]
217const CAR_COLORS = [0xf4f4f4, 0x2040c0, 0xe02020, 0xf0d020, 0x20a040, 0x101010, 0xf08020]
218export const SHIRTS = [0xe02828, 0x2858e0, 0xf0d028, 0x28b048, 0xe07818, 0xb030c8, 0xf4f4f4, 0x18c0c0, 0xf080a0]
219
220const LABELS = ['Low', 'Medium', 'High', 'Very High', 'Extreme', 'Ultra-Extreme']
221
222export function ratingLabel(v: number): string {
223 return LABELS[v < 2.5 ? 0 : v < 5 ? 1 : v < 6.5 ? 2 : v < 8 ? 3 : v < 9.5 ? 4 : 5]!
224}
225
226export function tierOf(model: string): Tier {
227 const m = model.toLowerCase()
228 if (m.includes('haiku')) return 'small'
229 if (m.includes('sonnet')) return 'medium'
230
231 return 'big'
232}
233
234export function crewName(model: string): string {
235 const family = /(opus|sonnet|haiku|fable|mythos)/i.exec(model)?.[1]
236
237 return family ? family[0]!.toUpperCase() + family.slice(1).toLowerCase() : model || 'Claude'
238}
239
240export function createWorld(): World {
241 const w: World = {
242 rides: [], guests: [], money: 10000, guestsTotal: 0, nextGuest: 1, rideCounter: 0,
243 trees: [], flowers: [], particles: [],
244 news: 'The gates are open! Give Claude some work and the crews start building.',
245 newsAt: 0, thought: '', time: 0, frame: 0, groundVer: 1,
246 spawnAcc: 0, thoughtAcc: 0, finishAcc: 0, isFinishing: false, isWorking: false,
247 toasts: [], milestone: 0, isDirty: true, camX: 9, camY: GY / 2,
248 cols: COLS_START, isShared: false, shareName: '',
249 }
250 plant(w, 0, gridW(w))
251
252 return w
253}
254
255// Trees and flowers on the land between columns x0 and x1.
256function plant(w: World, x0: number, x1: number) {
257 const area = (x1 - x0) * GY
258 for (let i = 0; i < area * 0.38; i++) {
259 const x = x0 + rnd() * (x1 - x0)
260 const y = rnd() * GY
261 if (Math.floor(y) === BLVD || (Math.floor(x) === 0 && Math.abs(y - BLVD - 0.5) < 1.5)) continue
262 if (w.rides.some(r => {
263 const f = footprint(r)
264 return x >= f.x0 - 0.2 && x < f.x0 + f.w + 0.2 && y >= f.y0 - 0.2 && y < f.y0 + f.h + 0.2
265 })) continue
266 w.trees.push({ x, y })
267 }
268 for (let x = Math.max(1, Math.floor(x0)); x < x1; x++) {
269 for (const y of [BLVD - 0.12, BLVD + 1.12]) {
270 if (rnd() < 0.5) w.flowers.push({ x: x + rnd(), y, c: pick([0xff4060, 0xffe040, 0xffffff, 0xc060ff]) })
271 }
272 }
273}
274
275// Buys the next strip of land at the end of the boulevard: two new plots.
276function expand(w: World) {
277 const before = gridW(w)
278 w.cols += 1
279 w.trees = w.trees.filter(t => t.x < before - 1)
280 plant(w, before - 1, gridW(w))
281 w.groundVer += 1
282 w.isDirty = true
283 say(w, `🌳 The park bought more land! ${w.cols * 2} plots now.`)
284 w.toasts.push(`🌳 The park expanded: ${w.cols * 2} plots`)
285}
286
287const SAVE_VERSION = 2
288
289export function saveWorld(w: World) {
290 return {
291 v: SAVE_VERSION,
292 money: Math.round(w.money),
293 guestsTotal: w.guestsTotal,
294 nextGuest: w.nextGuest,
295 rideCounter: w.rideCounter,
296 milestone: w.milestone,
297 cols: w.cols,
298 isShared: w.isShared,
299 shareName: w.shareName,
300 trees: w.trees.map(t => [+t.x.toFixed(2), +t.y.toFixed(2)]),
301 flowers: w.flowers.map(f => [+f.x.toFixed(2), +f.y.toFixed(2), f.c]),
302 rides: w.rides.map(({ train, queue, willCrash, ...rest }) => ({
303 ...rest,
304 nodes: rest.nodes.map(n => ({ ...n, x: +n.x.toFixed(3), y: +n.y.toFixed(3), h: +n.h.toFixed(3) })),
305 status: rest.status === 'testing' ? 'building' : rest.status,
306 })),
307 }
308}
309
310export function loadWorld(saved: unknown): World {
311 const w = createWorld()
312 const s = saved as ReturnType<typeof saveWorld> | undefined
313 if (!s || s.v !== SAVE_VERSION) return w
314 w.money = s.money
315 w.guestsTotal = s.guestsTotal
316 w.nextGuest = s.nextGuest
317 w.rideCounter = s.rideCounter
318 w.milestone = s.milestone ?? 0
319 w.cols = Math.min(COLS_MAX, Math.max(COLS_START, s.cols ?? COLS_START))
320 w.isShared = s.isShared ?? false
321 w.shareName = s.shareName ?? ''
322 w.trees = s.trees.map(([x, y]) => ({ x: x!, y: y! }))
323 w.flowers = s.flowers.map(([x, y, c]) => ({ x: x!, y: y!, c: c! }))
324 w.rides = s.rides.map(r => ({ ...(r as Ride), queue: [] }))
325 for (const r of w.rides) {
326 if (r.status === 'open') r.train = newTrain(false)
327 }
328 if (w.rides.length > 0) w.news = 'The park reopens its gates!'
329 w.isFinishing = w.rides.some(r => r.status === 'building')
330
331 return w
332}
333
334function newTrain(isTest: boolean): Train {
335 return { s: 0, mode: isTest ? 'run' : 'load', wait: 1, elapsed: 0, riders: [], isTest }
336}
337
338export function footprint(r: Ride): { x0: number; y0: number; w: number; h: number } {
339 const s = slotAt(r.slot)
340
341 return r.half < 0 ? s : { x0: s.x0 + r.half * 2, y0: s.y0, w: 2, h: s.h }
342}
343
344export function queueTile(r: Ride): { x: number; y: number } {
345 return { x: r.qx, y: r.qy }
346}
347
348// ---- Track layouts -------------------------------------------------------
349// Each is a closed path in the slot's own coordinates: u across (0..4), v
350// away from the boulevard (0..5). Every one starts along v = 0.5, where the
351// station sits next to the queue.
352
353type Pt = { u: number; v: number; isHelix?: true }
354
355function layoutPath(layout: Layout): Pt[] {
356 switch (layout) {
357 case 'oval':
358 return [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 4.5 }, { u: 0.5, v: 4.5 }]
359 case 'figure8':
360 return [
361 { u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 1.8 }, { u: 0.5, v: 3.2 },
362 { u: 0.5, v: 4.5 }, { u: 3.5, v: 4.5 }, { u: 3.5, v: 3.2 }, { u: 0.5, v: 1.8 },
363 ]
364 case 'outback':
365 return [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 2.7, v: 4.6 }, { u: 1.3, v: 4.6 }]
366 case 'helix': {
367 const path: Pt[] = [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 2.4 }]
368 const turns = 1.5
369 for (let k = 0; k <= 18; k++) {
370 const t = (k / 18) * Math.PI * 2 * turns
371 path.push({ u: 2 + Math.cos(t) * 1.1, v: 3.4 + Math.sin(t) * 1.05, isHelix: true })
372 }
373 path.push({ u: 0.5, v: 2.4 })
374
375 return path
376 }
377 case 'zigzag':
378 return [
379 { u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 1.5 }, { u: 1.1, v: 1.5 },
380 { u: 1.1, v: 2.5 }, { u: 3.5, v: 2.5 }, { u: 3.5, v: 3.5 }, { u: 1.1, v: 3.5 },
381 { u: 1.1, v: 4.5 }, { u: 0.4, v: 4.5 }, { u: 0.4, v: 1.2 },
382 ]
383 case 'twister': {
384 // A rectangle with every corner pulled in by a random amount, then
385 // smoothed: no two come out alike.
386 const ring: Pt[] = []
387 const side = (a: Pt, b: Pt, k: number) => {
388 for (let i = 0; i < k; i++) ring.push({ u: a.u + ((b.u - a.u) * i) / k, v: a.v + ((b.v - a.v) * i) / k })
389 }
390 side({ u: 3.5, v: 0.5 }, { u: 3.5, v: 4.5 }, 4)
391 side({ u: 3.5, v: 4.5 }, { u: 0.5, v: 4.5 }, 3)
392 side({ u: 0.5, v: 4.5 }, { u: 0.5, v: 0.5 }, 4)
393 const wobbly = ring.map(p => {
394 const pull = rnd() * 0.85
395 return { u: p.u + (2 - p.u) * pull * 0.6, v: p.v + (2.6 - p.v) * pull * 0.45 }
396 })
397 let path: Pt[] = [{ u: 0.5, v: 0.5 }, { u: 2.5, v: 0.5 }, ...wobbly.slice(1), { u: 0.5, v: 1 }]
398 for (let pass = 0; pass < 2; pass++) {
399 const next: Pt[] = [path[0]!, path[1]!]
400 for (let i = 1; i < path.length - 1; i++) {
401 const a = path[i]!
402 const b = path[i + 1]!
403 next.push({ u: a.u * 0.75 + b.u * 0.25, v: a.v * 0.75 + b.v * 0.25 }, { u: a.u * 0.25 + b.u * 0.75, v: a.v * 0.25 + b.v * 0.75 })
404 }
405 next.push(path[path.length - 1]!)
406 path = next
407 }
408
409 return path
410 }
411 }
412}
413
414function resample(path: Pt[], step: number): Pt[] {
415 const out: Pt[] = []
416 let carry = 0
417 for (let i = 0; i < path.length; i++) {
418 const a = path[i]!
419 const b = path[(i + 1) % path.length]!
420 const len = Math.hypot(b.u - a.u, b.v - a.v)
421 let d = carry
422 while (d < len) {
423 const t = d / len
424 out.push({ u: a.u + (b.u - a.u) * t, v: a.v + (b.v - a.v) * t, ...(a.isHelix && b.isHelix ? { isHelix: true as const } : {}) })
425 d += step
426 }
427 carry = d - len
428 }
429
430 return out
431}
432
433// An inversion spliced in after node `at`: a vertical loop rises in the plane
434// of travel, a corkscrew rolls sideways across it.
435function inversion(nodes: Node[], at: number, isCorkscrew: boolean): Node[] {
436 const a = nodes[at]!
437 const b = nodes[(at + 1) % nodes.length]!
438 const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
439 const dx = (b.x - a.x) / len
440 const dy = (b.y - a.y) / len
441 const out: Node[] = []
442 for (let k = 1; k <= 12; k++) {
443 const t = (k / 13) * Math.PI * 2
444 if (isCorkscrew) {
445 const along = (k / 13) * 1.2
446 out.push({
447 x: a.x + dx * along - dy * Math.sin(t) * 0.45,
448 y: a.y + dy * along + dx * Math.sin(t) * 0.45,
449 h: a.h + (1 - Math.cos(t)) * 0.7,
450 isLoop: true,
451 })
452 } else {
453 const side = (k / 13 - 0.5) * 0.35
454 out.push({
455 x: a.x + dx * 0.75 * Math.sin(t) - dy * side,
456 y: a.y + dy * 0.75 * Math.sin(t) + dx * side,
457 h: a.h + (1 - Math.cos(t)) * 1.6,
458 isLoop: true,
459 })
460 }
461 }
462
463 return out
464}
465
466function makeTrack(slot: Slot, spec: TrackSpec) {
467 const layout = pick(spec.layouts)
468 const isMirror = rnd() < 0.5
469 const pts = resample(layoutPath(layout), 0.5)
470 const toWorld = (p: Pt): Node => {
471 const u = isMirror ? 4 - p.u : p.u
472 return { x: slot.x0 + u, y: slot.isTop ? slot.y0 + slot.h - p.v : slot.y0 + p.v, h: 0 }
473 }
474 const nodes = pts.map(toWorld)
475 const n = nodes.length
476 const stationLen = 4
477 const hmax = between(...spec.hmax)
478 const hs = 0.35
479 const lift = clamp(Math.round(n * 0.22), 5, 12)
480 const liftEnd = stationLen + lift
481 let splashAt = -1
482 const helix = pts.map(p => p.isHelix === true)
483 const hills = 1 + Math.floor(rnd() * 3)
484 for (let i = 0; i < n; i++) {
485 const node = nodes[i]!
486 if (i < stationLen) {
487 node.h = hs
488 continue
489 }
490 if (i < liftEnd) {
491 node.h = hs + ((hmax - hs) * (i - stationLen + 1)) / lift
492 continue
493 }
494 if (i >= n - 2) {
495 node.h = hs + 0.1
496 continue
497 }
498 const t = (i - liftEnd) / Math.max(1, n - 2 - liftEnd)
499 if (spec.profile === 'mouse') {
500 node.h = t < 0.65 ? hmax - t * 0.9 : (hmax - 0.6) * (1 - (t - 0.65) / 0.35) + 0.4 + (i % 3 === 0 ? 0.25 : 0)
501 } else if (spec.profile === 'flume') {
502 node.h = t < 0.6 ? hmax - t * 0.8 : t < 0.7 ? hmax * 0.55 * (1 - (t - 0.6) / 0.1) + 0.2 : 0.25
503 if (splashAt < 0 && t >= 0.7) splashAt = i
504 } else if (i === liftEnd) {
505 node.h = hmax * 0.55
506 } else if (i === liftEnd + 1) {
507 node.h = 0.6
508 } else {
509
510 node.h = 0.6 + Math.abs(Math.sin(t * Math.PI * hills)) * hmax * 0.55 * (1 - t * 0.6)
511 }
512 if (helix[i]) node.h = Math.max(0.5, node.h * 0.6)
513 }
514
515 let loops = Math.round(between(spec.loops[0], spec.loops[1]))
516 let screws = Math.round(between(spec.corkscrews[0], spec.corkscrews[1]))
517 const spots: number[] = []
518 for (let i = liftEnd + 1; i < nodes.length - 4; i++) {
519 if (!helix[i] && nodes[i]!.h < 1.3 && spots.every(s => Math.abs(s - i) > 5)) spots.push(i)
520 }
521 const inserts: { at: number; isCorkscrew: boolean }[] = []
522 for (const at of spots) {
523 if (loops > 0) {
524 inserts.push({ at, isCorkscrew: false })
525 loops -= 1
526 } else if (screws > 0) {
527 inserts.push({ at, isCorkscrew: true })
528 screws -= 1
529 }
530 }
531 inserts.sort((a, b) => b.at - a.at)
532 for (const ins of inserts) nodes.splice(ins.at + 1, 0, ...inversion(nodes, ins.at, ins.isCorkscrew))
533 const inversions = inserts.length
534 if (splashAt >= 0) splashAt = nodes.findIndex((nd, i) => i > liftEnd && nd.h <= 0.3)
535
536 const stationU = isMirror ? 4 - 1.25 : 1.25
537 const qx = slot.x0 + Math.floor(stationU)
538 const qy = slot.isTop ? BLVD - 1 : BLVD + 1
539
540 return { nodes, stationLen, liftEnd, splashAt, qx, qy, layout, hmax, inversions, hasHelix: helix.some(Boolean) }
541}
542
543// ---- Building ------------------------------------------------------------
544
545function say(w: World, text: string) {
546 w.news = text
547 w.newsAt = w.time
548}
549
550function building(w: World, tier: Tier) {
551 return w.rides.find(r => r.status === 'building' && r.tier === tier)
552}
553
554function isBusy(r: Ride) {
555 return r.status === 'building' || r.status === 'testing'
556}
557
558function demolish(w: World, gone: Ride[]) {
559 for (const r of gone) {
560 evict(w, r)
561 say(w, `${r.name} was demolished to make room for something new.`)
562 w.toasts.push(`🚧 Demolished ${r.name} to make room`)
563 }
564 const ids = new Set(gone.map(r => r.id))
565 w.rides = w.rides.filter(r => !ids.has(r.id))
566}
567
568// Finds room for a ride: half of a shared plot for a flat ride, else an
569// empty plot, else the park buys more land. Only a park at its full size
570// makes room by demolishing, and then only the crew's own weakest ride.
571function place(w: World, isHalf: boolean, tier: Tier): { slot: number; half: number } | undefined {
572 const inSlot = (s: number) => w.rides.filter(r => r.slot === s)
573 const score = (r: Ride) => (r.status === 'crashed' ? -1 : r.excitement)
574 const worstOf = (list: Ride[]) => list.reduce((a, b) => (score(b) < score(a) ? b : a))
575 if (isHalf) {
576 for (let s = 0; s < w.cols * 2; s++) {
577 const rides = inSlot(s)
578 if (rides.length === 1 && rides[0]!.half >= 0) return { slot: s, half: 1 - rides[0]!.half }
579 }
580 }
581 let empty = -1
582 for (let s = 0; s < w.cols * 2 && empty < 0; s++) if (inSlot(s).length === 0) empty = s
583 if (empty < 0 && w.cols < COLS_MAX) {
584 expand(w)
585 empty = (w.cols - 1) * 2
586 }
587 if (empty >= 0) return { slot: empty, half: isHalf ? 0 : -1 }
588 if (isHalf) {
589 const mine = w.rides.filter(r => r.half >= 0 && r.tier === tier && !isBusy(r))
590 if (mine.length === 0) return undefined
591 const worst = worstOf(mine)
592 demolish(w, [worst])
593
594 return { slot: worst.slot, half: worst.half }
595 }
596 const mine = w.rides.filter(r => r.half < 0).filter(r => r.tier === tier && !isBusy(r))
597 if (mine.length === 0) return undefined
598 const worst = worstOf(mine)
599 demolish(w, [worst])
600
601 return { slot: worst.slot, half: -1 }
602}
603
604function startRide(w: World, tier: Tier, who: string): Ride | undefined {
605 const broken = w.rides.find(r => r.status === 'crashed' && r.tier === tier)
606 if (broken) {
607 broken.status = 'building'
608 broken.built = Math.max(1, Math.min(broken.built, broken.size - 6))
609 broken.calls = 0
610 broken.fails = 0
611 broken.builtBy = who
612 say(w, `The ${who} crew is repairing ${broken.name}.`)
613 w.isDirty = true
614
615 return broken
616 }
617 let track = tier === 'big' ? pick(BIG) : tier === 'medium' && rnd() < 0.5 ? pick(MEDIUM_TRACK) : undefined
618 let flat = track ? undefined : pick(tier === 'medium' ? MEDIUM_FLAT : SMALL_FLAT)
619 let spot = place(w, flat !== undefined, tier)
620 if (!spot && tier === 'medium') {
621 // No plot for a family coaster: Sonnet's crew builds a flat ride instead,
622 // or the other way about.
623 ;[track, flat] = track ? [undefined, pick(MEDIUM_FLAT)] : [pick(MEDIUM_TRACK), undefined]
624 spot = place(w, flat !== undefined, tier)
625 }
626 if (!spot) return undefined
627 const slot = slotAt(spot.slot)
628 const used = new Set(w.rides.map(r => r.name))
629 const fresh = NAMES[tier].filter(n => !used.has(n))
630 w.rideCounter += 1
631 const kind = track?.kind ?? flat!.kind
632 const ride: Ride = {
633 id: w.rideCounter,
634 name: fresh.length > 0 ? pick(fresh) : `${kind} ${w.rideCounter}`,
635 kind, tier, cat: track ? 'track' : 'flat', builtBy: who,
636 slot: spot.slot, half: spot.half,
637 color: pick(TRACK_COLORS), carColor: pick(CAR_COLORS),
638 support: track?.isWood ? 0x8a5a2a : 0xb8b8c0, isWood: track?.isWood ?? false,
639 cars: track?.cars ?? 0, capacity: track ? track.cars * 2 : flat!.capacity,
640 nodes: [], built: 0, size: flat?.size ?? 0, stationLen: 0, liftEnd: 0, splashAt: -1,
641 cx: 0, cy: 0, qx: 0, qy: 0, status: 'building',
642 excitement: 0, intensity: 0, nausea: 0, price: 0, riders: 0,
643 calls: 0, fails: 0, crashAt: 0, queue: [],
644 }
645 if (track) {
646 const t = makeTrack(slot, track)
647 Object.assign(ride, { nodes: t.nodes, size: t.nodes.length, stationLen: t.stationLen, liftEnd: t.liftEnd, splashAt: t.splashAt, qx: t.qx, qy: t.qy })
648 ride.excitement = track.base + t.hmax * 0.5 + t.inversions * 0.9 + (t.hasHelix ? 0.6 : 0) + (t.layout === 'figure8' ? 0.4 : 0)
649 ride.intensity = 1 + t.hmax * 0.7 + t.inversions * 1.1 + (t.hasHelix ? 0.8 : 0)
650 ride.nausea = 0.4 + t.inversions * 1.1 + (t.hasHelix ? 1 : 0)
651 if (track.kind === 'Log Flume') ride.carColor = 0x8a5a2a
652 if (track.kind === 'Log Flume') ride.color = 0x3a90e0
653 } else {
654 const u = spot.half * 2 + 1
655 ride.cx = slot.x0 + u
656 ride.cy = slot.isTop ? slot.y0 + slot.h - 1.7 : slot.y0 + 1.7
657 ride.qx = slot.x0 + spot.half * 2 + (slot.isTop ? 1 : 0)
658 ride.qy = slot.isTop ? BLVD - 1 : BLVD + 1
659 ride.excitement = flat!.e
660 ride.intensity = flat!.i
661 ride.nausea = flat!.n
662 }
663 const f = footprint(ride)
664 w.trees = w.trees.filter(t => !(t.x >= f.x0 - 0.2 && t.x < f.x0 + f.w + 0.2 && t.y >= f.y0 - 0.2 && t.y < f.y0 + f.h + 0.2))
665 w.rides.push(ride)
666 w.groundVer += 1
667 w.isDirty = true
668 say(w, `The ${who} crew started building ${ride.name} (${ride.kind}).`)
669
670 return ride
671}
672
673function evict(w: World, r: Ride) {
674 const ids = new Set([...r.queue, ...(r.train?.riders ?? [])])
675 for (const g of w.guests) {
676 if (ids.has(g.id) || g.rideId === r.id) {
677 if (g.state === 'ride' || g.state === 'queue') {
678 g.x = r.qx + 0.5
679 g.y = BLVD + 0.5
680 }
681 g.state = 'walk'
682 g.rideId = -1
683 g.tx = g.x
684 }
685 }
686 r.queue = []
687 if (r.train) r.train.riders = []
688}
689
690const PIECES: Record<string, number> = {
691 Edit: 3, Write: 4, MultiEdit: 4, NotebookEdit: 3, Bash: 2, Agent: 4, Task: 4,
692}
693
694export function toolCalled(w: World, tool: string, model: string): string {
695 w.isWorking = true
696 const tier = tierOf(model)
697 const who = crewName(model)
698 const ride = building(w, tier) ?? startRide(w, tier, who)
699 if (!ride) return ''
700 const k = PIECES[tool] ?? 1
701 ride.built = Math.min(ride.size, ride.built + k)
702 ride.calls += 1
703 w.money -= k * (tier === 'big' ? 60 : tier === 'medium' ? 40 : 25)
704 w.isDirty = true
705 if (ride.built >= ride.size) startTest(w, ride)
706
707 return ride.name
708}
709
710export function toolFailed(w: World, rideName: string) {
711 const ride = w.rides.find(r => r.name === rideName)
712 if (ride) ride.fails += 1
713}
714
715export function turnEnded(w: World, reason: string) {
716 w.isWorking = false
717 const open = w.rides.filter(r => r.status === 'building')
718 if (open.length === 0) return
719 if (reason === 'aborted' || reason === 'error') {
720 for (const r of open) startTest(w, r)
721 } else {
722 w.isFinishing = true
723 }
724}
725
726export function turnStarted(w: World) {
727 w.isWorking = true
728}
729
730function startTest(w: World, r: Ride) {
731 r.status = 'testing'
732 r.train = newTrain(true)
733 const isShaky = r.fails >= 3 && r.fails / Math.max(1, r.calls) >= 0.35
734 r.willCrash = r.built < r.size || isShaky
735 if (r.cat === 'track') {
736 r.crashAt = r.built < r.size ? Math.max(1, r.built - 1) : Math.min(r.size - 2, r.liftEnd + 3)
737 }
738 say(w, r.built < r.size ? `Test run on ${r.name}... but it isn't finished!` : `Test run: ${r.name}`)
739 w.isDirty = true
740}
741
742function open(w: World, r: Ride) {
743 const jitter = () => rnd() * 0.9 - 0.3
744 const penalty = r.fails * 0.12
745 r.intensity = clamp(r.intensity + jitter(), 0.3, 11)
746 r.excitement = clamp(r.excitement + jitter() - penalty - Math.max(0, r.intensity - 9) * 0.6, 0.3, 9.9)
747 r.nausea = clamp(r.nausea + r.intensity * 0.15 + jitter(), 0.2, 10)
748 r.price = Math.max(1, Math.round(r.excitement * 1.3))
749 r.status = 'open'
750 r.train = newTrain(false)
751 say(w, `${r.name} is open! Excitement ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)}), intensity ${r.intensity.toFixed(2)}.`)
752 w.toasts.push(`${r.cat === 'track' ? '🎢' : '🎡'} ${r.name} (${r.kind}, built by ${r.builtBy}) is OPEN · Excitement ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)})`)
753 w.isDirty = true
754}
755
756function crash(w: World, r: Ride, at: Node, why: string) {
757 explode(w, at, r.cat === 'track' ? 46 : 20)
758 r.status = 'crashed'
759 r.train = undefined
760 r.willCrash = false
761 say(w, `💥 ${r.name} ${why}! The test was empty, luckily.`)
762 w.toasts.push(`💥 ${r.name} ${why}`)
763 w.isDirty = true
764}
765
766export function nodeAt(r: Ride, s: number): Node {
767 const n = r.nodes.length
768 const i = ((Math.floor(s) % n) + n) % n
769 const f = s - Math.floor(s)
770 const a = r.nodes[i]!
771 const b = r.nodes[(i + 1) % n]!
772
773 return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, h: a.h + (b.h - a.h) * f }
774}
775
776function speedAt(r: Ride, s: number): number {
777 const n = r.nodes.length
778 const i = Math.floor(s)
779 const k = r.kind === 'Log Flume' ? 0.7 : r.tier === 'medium' ? 0.85 : 1
780 if (i < r.liftEnd) return 2.2
781 if (i >= n - 3) return 2.8
782 const top = r.nodes[r.liftEnd - 1]!.h
783
784 return 2.2 + 3.4 * k * Math.sqrt(Math.max(0, top - nodeAt(r, s).h))
785}
786
787function explode(w: World, at: Node, count: number) {
788 for (let i = 0; i < count; i++) {
789 const t = rnd() * Math.PI * 2
790 const v = 0.4 + rnd() * 1.6
791 w.particles.push({
792 x: at.x, y: at.y, h: at.h,
793 vx: Math.cos(t) * v * 0.6, vy: Math.sin(t) * v * 0.6, vh: 1 + rnd() * 3,
794 life: 1 + rnd() * 1.4, c: pick([0xffe040, 0xff9020, 0xff3010, 0x808080, 0xffffff]),
795 })
796 }
797}
798
799function splash(w: World, at: Node) {
800 for (let i = 0; i < 10; i++) {
801 const t = rnd() * Math.PI * 2
802 w.particles.push({
803 x: at.x, y: at.y, h: 0.2, vx: Math.cos(t) * 0.5, vy: Math.sin(t) * 0.5, vh: 1.5 + rnd() * 2,
804 life: 0.6 + rnd() * 0.4, c: pick([0xffffff, 0xa0d8ff, 0x60b0f0]),
805 })
806 }
807}
808
809// Riders leave, the queue boards; the ride takes its fares.
810function board(w: World, r: Ride) {
811 const t = r.train!
812 for (const id of t.riders) {
813 const g = w.guests.find(one => one.id === id)
814 if (!g) continue
815 g.state = 'walk'
816 g.x = r.qx + 0.5
817 g.y = BLVD + 0.2 + rnd() * 0.6
818 g.rides += 1
819 g.lastRide = r.name
820 choose(w, g)
821 }
822 t.riders = r.queue.splice(0, r.capacity)
823 for (const id of t.riders) {
824 const g = w.guests.find(one => one.id === id)
825 if (g) g.state = 'ride'
826 }
827 w.money += t.riders.length * r.price
828 r.riders += t.riders.length
829}
830
831const FLAT_RUN = 7
832const FLAT_TEST = 4
833
834function stepFlat(w: World, r: Ride, dt: number) {
835 const t = r.train!
836 if (t.mode === 'load') {
837 t.wait -= dt
838 if (t.wait <= 0) {
839 t.mode = 'run'
840 t.elapsed = 0
841 }
842 return
843 }
844 t.elapsed += dt
845 t.s += dt
846 const center: Node = { x: r.cx, y: r.cy, h: 1 }
847 if (t.isTest) {
848 if (r.willCrash && t.elapsed >= FLAT_TEST / 2) {
849 crash(w, r, center, r.built < r.size ? 'collapsed: it was never finished' : 'broke down: too many failed inspections')
850 } else if (t.elapsed >= FLAT_TEST) {
851 open(w, r)
852 }
853 return
854 }
855 if (t.elapsed < FLAT_RUN) return
856 board(w, r)
857 t.mode = 'load'
858 t.wait = 2
859}
860
861function stepTrack(w: World, r: Ride, dt: number) {
862 const t = r.train!
863 const n = r.nodes.length
864 if (t.mode === 'load') {
865 t.wait -= dt
866 if (t.wait <= 0) t.mode = 'run'
867 return
868 }
869 const before = t.s
870 t.s += speedAt(r, t.s) * dt
871 if (r.splashAt > 0 && before < r.splashAt && t.s >= r.splashAt) splash(w, nodeAt(r, r.splashAt))
872 if (t.isTest && r.willCrash && t.s >= r.crashAt) {
873 const why = r.built < r.size ? 'flew off the unfinished track' : 'derailed: too many failed inspections'
874 crash(w, r, nodeAt(r, r.crashAt), why)
875 return
876 }
877 if (t.s < n) return
878 t.s = 0
879 if (t.isTest) {
880 open(w, r)
881 return
882 }
883 board(w, r)
884 t.mode = 'load'
885 t.wait = 1.8
886}
887
888function choose(w: World, g: Guest) {
889 const open = w.rides.filter(r => r.status === 'open' && r.queue.length < 22)
890 if (g.rides >= g.maxRides || (open.length === 0 && rnd() < 0.4)) {
891 g.state = 'leave'
892 g.tx = 0.25
893 g.rideId = -1
894 return
895 }
896 if (open.length === 0) {
897 g.state = 'walk'
898 g.tx = 1 + rnd() * (gridW(w) - 2)
899 g.rideId = -1
900 g.rides += 0.5
901 return
902 }
903 const weights = open.map(r => (r.excitement + 1.5) ** 1.6 * (r.intensity > 8.5 ? 0.35 : 1))
904 let roll = rnd() * weights.reduce((a, b) => a + b, 0)
905 let target = open[0]!
906 for (let i = 0; i < open.length; i++) {
907 roll -= weights[i]!
908 if (roll <= 0) {
909 target = open[i]!
910 break
911 }
912 }
913 g.state = 'walk'
914 g.rideId = target.id
915 g.tx = target.qx + 0.5
916}
917
918function stepGuests(w: World, dt: number) {
919 const open = w.rides.filter(r => r.status === 'open')
920 const pull = open.reduce((a, r) => a + r.excitement / 5, 0)
921 const rate = open.length === 0 ? 0.04 : Math.min(0.75 * w.cols, 0.2 + pull * 0.4)
922 w.spawnAcc += rate * dt
923 while (w.spawnAcc >= 1) {
924 w.spawnAcc -= 1
925 if (w.guests.length >= Math.min(400, 50 * w.cols)) continue
926 const g: Guest = {
927 id: w.nextGuest++, x: 0.2, y: BLVD + 0.15 + rnd() * 0.7, tx: 0,
928 state: 'walk', rideId: -1, rides: 0, maxRides: 2 + Math.floor(rnd() * 4),
929 shirt: pick(SHIRTS), speed: 1 + rnd() * 0.8,
930 }
931 choose(w, g)
932 w.guests.push(g)
933 w.guestsTotal += 1
934 w.money += 20
935 }
936 const byId = new Map(w.rides.map(r => [r.id, r]))
937 const gone = new Set<number>()
938 for (const g of w.guests) {
939 if (g.state === 'ride' || g.state === 'queue') {
940 const r = byId.get(g.rideId)
941 if (!r || r.status !== 'open') {
942 g.state = 'walk'
943 g.y = BLVD + 0.5
944 choose(w, g)
945 }
946 continue
947 }
948 if (g.state === 'toQueue') {
949 const r = byId.get(g.rideId)
950 if (!r || r.status !== 'open') {
951 choose(w, g)
952 continue
953 }
954 const dy = r.qy + 0.5 - g.y
955 g.y += Math.sign(dy) * Math.min(Math.abs(dy), g.speed * dt)
956 if (Math.abs(dy) < 0.05) {
957 g.state = 'queue'
958 r.queue.push(g.id)
959 }
960 continue
961 }
962 const dx = g.tx - g.x
963 g.x += Math.sign(dx) * Math.min(Math.abs(dx), g.speed * dt)
964 if (Math.abs(dx) > 0.04) continue
965 if (g.state === 'leave') {
966 gone.add(g.id)
967 continue
968 }
969 const r = byId.get(g.rideId)
970 if (r && r.status === 'open' && r.queue.length < 22) g.state = 'toQueue'
971 else choose(w, g)
972 }
973 if (gone.size > 0) w.guests = w.guests.filter(g => !gone.has(g.id))
974}
975
976const GENERAL = [
977 'This park is really clean and tidy',
978 "I'm hungry",
979 "I'm thirsty",
980 'I need to find a toilet',
981 'I want to go on something more thrilling',
982 'The scenery here is beautiful',
983 "I'm not paying that much to go on that",
984 'Great value for money!',
985]
986
987function think(w: World) {
988 const g = w.guests[Math.floor(rnd() * w.guests.length)]
989 if (!g) return
990 const r = w.rides.find(one => one.id === g.rideId)
991 let text = pick(GENERAL)
992 if (g.state === 'queue' && r && r.queue.length > 12) text = `The queue for ${r.name} is really long`
993 else if (g.state === 'ride' && r) text = r.intensity > 7.5 ? `${r.name} is terrifying!` : `Wheee! ${r.name}!`
994 else if (g.lastRide && rnd() < 0.6) {
995 const last = w.rides.find(one => one.name === g.lastRide)
996 text = last && last.nausea > 6 ? `I feel sick after ${g.lastRide}` : `${g.lastRide} was great!`
997 } else if (r && r.intensity > 8.5 && rnd() < 0.5) text = `${r.name} looks too intense for me`
998 w.thought = `Guest ${g.id}: "${text}"`
999}
1000
1001export function step(w: World, dt: number) {
1002 w.time += dt
1003 w.frame += 1
1004 if (w.isFinishing) {
1005 const left = w.rides.filter(r => r.status === 'building')
1006 if (left.length === 0) w.isFinishing = false
1007 w.finishAcc += dt
1008 while (w.finishAcc >= 0.12) {
1009 w.finishAcc -= 0.12
1010 for (const ride of left) {
1011 if (ride.status !== 'building') continue
1012 ride.built += 1
1013 w.money -= 40
1014 if (ride.built >= ride.size) startTest(w, ride)
1015 }
1016 }
1017 }
1018 for (const r of w.rides) {
1019 if (!r.train) continue
1020 if (r.cat === 'flat') stepFlat(w, r, dt)
1021 else stepTrack(w, r, dt)
1022 }
1023 stepCamera(w, dt)
1024 stepGuests(w, dt)
1025 for (const p of w.particles) {
1026 p.x += p.vx * dt
1027 p.y += p.vy * dt
1028 p.vh -= 6 * dt
1029 p.h = Math.max(0, p.h + p.vh * dt)
1030 p.life -= dt
1031 }
1032 w.particles = w.particles.filter(p => p.life > 0)
1033 w.thoughtAcc += dt
1034 if (w.thoughtAcc > 4.5) {
1035 w.thoughtAcc = 0
1036 think(w)
1037 }
1038 const marks = [25, 50, 100, 150, 200]
1039 const next = marks.find(m => m > w.milestone)
1040 if (next && w.guests.length >= next) {
1041 w.milestone = next
1042 w.toasts.push(`🎉 ${next} guests in the park!`)
1043 }
1044}
1045
1046export function parkRating(w: World): number {
1047 const open = w.rides.filter(r => r.status === 'open')
1048 const fun = open.reduce((a, r) => a + r.excitement, 0)
1049 const crashed = w.rides.filter(r => r.status === 'crashed').length
1050
1051 return Math.round(clamp(250 + fun * 20 + w.guests.length * 1.1 + w.trees.length - crashed * 150, 0, 999))
1052}
1053
1054export function buildingRides(w: World) {
1055 return w.rides.filter(r => r.status === 'building')
1056}
1057
1058// The camera drifts to the action: a test run, a build site, a wreck, else
1059// it tours the open rides.
1060export function stepCamera(w: World, dt: number) {
1061 const focus =
1062 w.rides.find(r => r.status === 'testing') ??
1063 w.rides.find(r => r.status === 'building') ??
1064 w.rides.find(r => r.status === 'crashed')
1065 const open = w.rides.filter(r => r.status === 'open')
1066 const ride = focus ?? open[Math.floor(w.time / 12) % Math.max(1, open.length)]
1067 let tx = gridW(w) / 2
1068 let ty = BLVD + 0.5
1069 if (ride) {
1070 const f = footprint(ride)
1071 const s = slotAt(ride.slot)
1072 tx = f.x0 + f.w / 2
1073 ty = f.y0 + f.h / 2 + (s.isTop ? 0.8 : -0.8)
1074 }
1075 const k = Math.min(1, dt * 1.2)
1076 w.camX += (tx - w.camX) * k
1077 w.camY += (ty - w.camY) * k
1078}
1079types/index.d.ts 38 lines1export type ParkTick = number
2
3export type ParkView = 'park' | 'leaderboard'
4
5export type LeaderboardEntry = {
6 rank: number
7 user: string
8 id: string
9 name: string
10 score: number
11 rating: number
12 guests: number
13 guestsTotal: number
14 ridesOpen: number
15 coasters: number
16 riders: number
17 best: string
18 updatedAt: string
19}
20
21export type Leaderboard = {
22 updatedAt: string
23 players: number
24 entries: LeaderboardEntry[]
25}
26
27export type ParkBoard = {
28 board: Leaderboard | null
29 fetchedAt: number
30 error: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'coaster-tycoon': { tick: ParkTick; view: ParkView; board: ParkBoard }
36 }
37}
38