An idle pixel-art city in a side pane that grows as you prompt, edit and run commands. Open it with /city.

An idle pixel-art city for Claude Code. It lives in a side pane and grows while you work: every prompt brings new citizens, every edit earns bricks, every command powers a factory, and the tokens you use buy landmarks, from a bed of flowers all the way up to a Dyson swarm.

Each project gets its own city. Sessions in the same project build it together, sessions in other projects show up as skylines on the horizon, and the map shows every city you've built as an island in the same sea.
In a Claude Code terminal session:
/plugin install code-city --marketplace verdantran/code-city
Answer y to add the marketplace, then pick a scope (user scope makes it available in every project). Type /city whenever you want to see your city.
By default the pane only opens when you type /city, so it never appears in a project unless you ask for it. To have it open at the start of every session instead, turn on Open the city at the start of every session. The install offers this setting, and you can change it later in /config. Even with it on, the pane only opens by itself in terminals wide enough to fit it beside the conversation.
| Command or key | What it does |
|---|---|
/city | Open the pane and show the city's size and balance |
/city shop | Open the shop under the city |
/city buy <item> | Buy by exact name or a unique prefix of 3+ letters, e.g. /city buy ferris |
/city map | Switch between the street and the map of all your cities |
s / v / z | Shop, map and zoom |
| An item's key | Buy that item while the shop is open (each button shows its key, e.g. a: 150k) |
a / d | Scroll the street left or right while the shop is shut; auto-scroll resumes 3 seconds after the last press |
The keys work once the pane has the keyboard: click it, or press ctrl+x tab. You can also click any button.
Your work feeds the city:
| You... | The city gets |
|---|---|
| Send a prompt | 2 new citizens and a few bricks |
| Edit a file | Bricks, more for bigger edits |
| Create a file | Bricks and a new citizen |
| Run a shell command | Power, which builds factories and keeps them making bricks |
| Run tests that pass | A rainbow, and a new park |
| Run a command that fails | A brief shower |
| Make a commit | Fireworks |
| Launch a subagent | A drone that flies over the city while it works, and a new citizen |
| Use tokens | A balance to spend in the shop (cache reads count a tenth, matching how they're billed) |
Between your actions the city keeps building on its own: cranes raise new floors, empty lots fill with houses, shops and towers, citizens bring in a trickle of bricks, and each factory turns a little power into bricks while the power lasts. As it grows it moves up through six sizes:
| Size | Citizens |
|---|---|
| Campsite | 0 |
| Hamlet | 10 |
| Village | 30 |
| Town | 80 |
| City | 200 |
| Metropolis | 500 |
At Town size a subway starts tunnelling under the street, with stations and trains; at 300 citizens a second line follows.
The city has a life of its own:

The tokens you use pile up as a balance, and the shop turns them into things for your city. There are 33 items, from 25k to 2B tokens:
The pane's header always shows your balance and either how many items you can afford or how far you are from the next one.
Each project folder has its own city, and they all know about each other.
v, or /city map) shows up to 40 of your most recently active cities as islands, biggest first. Each island flies a flag, and yours is marked. A flag blinks while a session there is working, and its drones fly back and forth overhead. Press z to zoom between near, mid and far.
Only names, sizes and skyline shapes are shared between sessions, never what you're working on. See below for exactly what's stored.
The city is drawn in 24-bit colour. If your terminal only reports 256 colours, the dark and muted shades (the night sky, buildings after dark) get rounded to greys, while bright colours like lit windows survive. This is common in WSL under Windows Terminal, which supports full colour but doesn't say so. Add this to your ~/.bashrc (or your shell's equivalent) and restart Claude Code:
export COLORTERM=truecolor
Nothing is ever written inside your projects. Everything lives under your home folder:
| Path | Contents |
|---|---|
~/.claude/code-city/cities/<project-path>--<hash>/ | Each project's city: buildings, counters and token totals, plus a small lock file and one earnings file per session (numbers only) |
~/.claude/code-city/presence/<id>.json | Each running session's project name, city size, whether it's working right now and the types of any running subagents, so sessions can see each other. <id> is a random ID the plugin makes up, not Claude Code's session ID. Blanked when the session ends. |
~/.claude/plugins/store/code-city_*.json | A private backup of each city, keyed by project path |
No prompts, code, commands, file contents, file names or subagent task descriptions are written to disk. What is written still says something about how you work, though:
None of this leaves your computer, but anyone who can read your home folder can see it. On a computer shared with other accounts, chmod 700 ~/.claude/code-city keeps these files private to you.
Code City was written using Claude Code. AI-written code can contain mistakes that look plausible, so please treat this plugin like any other code from the internet: read it before you run it.
What has been done to check it:
claude plugin test .) covers the game logic, the shop, syncing between sessions, and how the plugin handles damaged or unexpected files.claude plugin validate . passes. It lists every event the plugin hooks into and every capability it uses.None of that replaces your own judgement. A plugin runs inside Claude Code with your permissions, so before installing it, check that it does what it says. As a starting point, the plugin is designed to:
~/.claude/code-city and its own backup in Claude Code's plugin store (see the table above).The quickest way to confirm this is to run claude plugin validate . on a clone and read its report, then skim hooks/register.tsx, where every hook is registered. If anything looks wrong, please open an issue.
From a terminal:
claude plugin uninstall code-city
To remove its data as well, delete ~/.claude/code-city and ~/.claude/plugins/store/code-city_*.json.
The plugin is a hooks module (hooks/register.tsx) with plain TypeScript helpers alongside it. To run a working copy:
git clone https://github.com/verdantran/code-city.git
claude --plugin-dir ./code-city
Claude Code writes the API type declarations into .claude-plugin/types/ when it loads the plugin, after which tsc -p . type-checks it.
claude plugin validate .
claude plugin test .
MIT, see LICENSE.
hooks/register.tsx 847 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentView, City, District, ItemId, Ledger, LogLine, Neighbour, Pan, View } from '../types'
5import { agentColor, frame, happenings, hex, milestones, newCity, nudge, pack, skyAt, stationsOf, tick, tierOf } from './city'
6import { paintRegion, ZOOMS } from './iso'
7import { clean, fnv, isOurFile, MAX_SESSIONS, num, sanitizeCity, sanitizeLedger, sanitizeNeighbour, stamp } from './safe'
8import type { FileKind } from './safe'
9import { balanceOf, buy, CATALOG, formatTokens, owns, weighTokens } from './shop'
10import { applyLedger, contest, emptyLedger, LEDGER_FRESH_MS, pruneApplied, slugOf } from './sync'
11import type { Lease } from './sync'
12import { weatherAt, weatherLabel } from './weather'
13
14const PANE = 'code-city'
15const city = atom({ plugin: 'code-city', key: 'city' } as const, newCity(0))
16const ledger = atom({ plugin: 'code-city', key: 'ledger' } as const, emptyLedger())
17const log = atom({ plugin: 'code-city', key: 'log' } as const, [] as LogLine[])
18/** The log as stored, re-checked: a session that hot-reloads keeps what older code wrote. */
19export const logLines = (value: unknown): LogLine[] =>
20 Array.isArray(value)
21 ? value
22 .filter((l): l is { at: unknown; text: unknown } => typeof l === 'object' && l !== null)
23 .map(l => ({ at: num(l.at, 0, 1e15), text: clean(l.text, 120) }))
24 .filter(l => l.text)
25 : []
26const view = atom({ plugin: 'code-city', key: 'view' } as const, 'street' as View)
27// Renamed from older keys, so whatever older code stored there is never drawn.
28const region = atom({ plugin: 'code-city', key: 'districts' } as const, [] as District[])
29const zoom = atom({ plugin: 'code-city', key: 'zoom' } as const, 1)
30const ZOOM_NAMES = ['near', 'mid', 'far']
31const agents = atom({ plugin: 'code-city', key: 'drones' } as const, [] as AgentView[])
32const neighbours = atom({ plugin: 'code-city', key: 'nearby' } as const, [] as Neighbour[])
33const sessionsHere = atom({ plugin: 'code-city', key: 'sessionsHere' } as const, 1)
34const self = atom({ plugin: 'code-city', key: 'self' } as const, '')
35const isShopOpen = atom({ plugin: 'code-city', key: 'isShopOpen' } as const, false)
36const LIVE = ['pending', 'running', 'waiting', 'idle']
37const STALE_MS = 15_000
38const MAX_REGION = 40
39const REGION_SCAN = 200
40const MAX_NEIGHBOURS = 50
41const WRITE_TIMEOUT_MS = 5_000
42const TOAST_GAP_MS = 10_000
43const LOG_KEEP = 40
44const MAX_READ = 1_000_000
45const SESSION_FILE = /^[A-Za-z0-9-]{1,64}\.json$/
46const PAN_STEP = 4
47const PAN_HOLD_MS = 3_000
48
49const lines = (s: string | undefined) => (s ? s.split('\n').length : 0)
50const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
51const projectOf = (cwd: string) => clean(cwd.split('/').filter(Boolean).pop() ?? cwd, 48) || 'project'
52
53/** The folder a project's city lives in: the readable slug plus a hash of the full path, so near-identical paths stay apart. */
54export const cityFolderOf = (cwd: string) => `${slugOf(cwd)}--${fnv(cwd).slice(0, 6)}`
55const HASHED = /--[0-9a-f]{6}$/
56
57const local = {
58 storeKey: 'city',
59 root: '',
60 rootReal: '',
61 cityDir: '',
62 citiesDir: '',
63 presenceDir: '',
64 slug: '',
65 legacySlug: '',
66 project: '',
67 turnTokens: 0,
68 isBusy: false,
69 self: '',
70 isMayor: false,
71 isOffline: false,
72 corrupt: 0,
73 hasSynced: false,
74 isEnded: false,
75 isClosing: false,
76 reported: new Set<string>(),
77 lastToast: 0,
78 cityCache: new Map<string, { mtime: number; size: number; city: City }>(),
79 inboxQueue: Promise.resolve() as Promise<unknown>,
80 presenceWrite: Promise.resolve() as Promise<unknown>,
81 timers: [] as { cancel: () => void }[],
82 pan: { at: 0, since: 0 } as Pan,
83}
84
85const paths = () => ({
86 city: `${local.cityDir}/city.json`,
87 lease: `${local.cityDir}/mayor.json`,
88 inbox: `${local.cityDir}/inbox`,
89 presence: `${local.presenceDir}/${local.self}.json`,
90})
91
92/** Parsed JSON from a regular file of ours, or undefined: no links, pipes or oversized files. */
93async function readSafe($: EngineInterface, path: string): Promise<unknown> {
94 if (local.isOffline) return undefined
95 const stat = await $.fs.stat(path).catch(() => undefined)
96 if (!stat || stat.kind !== 'file' || stat.isLink || stat.size > MAX_READ) return undefined
97 return $.fs
98 .read(path)
99 .then(text => JSON.parse(text) as unknown)
100 .catch(() => undefined)
101}
102
103const sameName = (a: string, b: string) => a.normalize('NFC').toLowerCase() === b.normalize('NFC').toLowerCase()
104
105/** Whether a write to `path` lands inside our own folder: the file, or its nearest existing folder, resolves under it. */
106async function isOurs($: EngineInterface, path: string) {
107 if (!local.rootReal || !path.startsWith(`${local.root}/`)) return false
108 for (let p = path; p.length >= local.root.length; p = p.slice(0, p.lastIndexOf('/'))) {
109 const stat = await $.fs.stat(p, { resolve: true }).catch(() => undefined)
110 if (!stat) {
111 // Nothing resolves here; refuse if an entry by this name exists all the same (a link to nowhere).
112 // Names compare without case: macOS folders treat "A.json" and "a.json" as one file.
113 const parent = p.slice(0, p.lastIndexOf('/'))
114 const name = p.slice(p.lastIndexOf('/') + 1)
115 const entries = await $.fs.list(parent).catch(() => [])
116 if (entries.some(f => sameName(f.name, name))) return false
117 continue
118 }
119 if (p === path && (stat.isLink || stat.kind !== 'file')) return false
120 const real = stat.realPath ?? ''
121 return real === local.rootReal || real.startsWith(`${local.rootReal}/`)
122 }
123 return false
124}
125
126/** Resolves to the work's result, or undefined once `ms` pass; the work itself carries on. */
127function within<T>($: EngineInterface, ms: number, work: Promise<T>): Promise<T | undefined> {
128 return new Promise(resolve => {
129 $.clock.after(Math.max(1, ms), () => resolve(undefined))
130 work.then(resolve, () => resolve(undefined))
131 })
132}
133
134/**
135 * Writes inside our folder only, and only over a file that is empty or already holds our own stamped data
136 * of this kind: a hard link to some other file can't be told apart by path, but it won't carry our stamp.
137 */
138async function writeSafe($: EngineInterface, kind: FileKind, path: string, data: object) {
139 if (local.isOffline || !(await isOurs($, path))) return false
140 const stat = await $.fs.stat(path).catch(() => undefined)
141 if (stat && stat.size > 0 && !isOurFile(kind, await readSafe($, path))) {
142 await warnOnce($, kind, path)
143 return false
144 }
145 await $.fs.write(path, stamp(kind, data))
146 return true
147}
148
149async function warnOnce($: EngineInterface, kind: string, path: string) {
150 if (local.reported.has(kind)) return
151 local.reported.add(kind)
152 await note($, `Code City won't overwrite ${path}: it isn't a ${kind} file it wrote. Delete it to recover.`)
153}
154
155async function readNeighbours($: EngineInterface, now: number) {
156 const entries = (await $.fs.list(local.presenceDir).catch(() => []))
157 .filter(f => f.kind === 'file' && !f.isLink && SESSION_FILE.test(f.name) && f.name !== `${local.self}.json` && now - f.mtimeMs < STALE_MS)
158 .sort((a, b) => b.mtimeMs - a.mtimeMs)
159 .slice(0, MAX_NEIGHBOURS)
160 const others: Neighbour[] = []
161 let here = 0
162 for (const f of entries) {
163 const n = sanitizeNeighbour(await readSafe($, `${local.presenceDir}/${f.name}`))
164 if (!n) continue
165 if (n.slug === local.slug) here += 1
166 else others.push(n)
167 }
168 return { others: others.sort((a, b) => a.id.localeCompare(b.id)), here }
169}
170
171/** The shared city as this session sees it: with its own earnings the mayor has not collected yet. */
172async function seen($: EngineInterface) {
173 const [shared, mine, me] = [await read($, city), await read($, ledger), await read($, self)]
174 return applyLedger(shared, me || 'local', mine, 0)
175}
176
177const bonus = (c: City) => (owns(c, 'brickworks') ? 1.5 : 1)
178
179/** An exact item id or name, or a prefix of 3+ letters that matches only one item. */
180export function findItem(query: string) {
181 const q = query.trim().toLowerCase()
182 if (!q) return undefined
183 const exact = CATALOG.find(i => i.id === q || i.name.toLowerCase() === q)
184 if (exact || q.length < 3) return exact
185 const starts = CATALOG.filter(i => i.id.startsWith(q) || i.name.toLowerCase().startsWith(q) || i.name.toLowerCase().split(' ').some(w => w.startsWith(q)))
186 return starts.length === 1 ? starts[0] : undefined
187}
188
189async function record($: EngineInterface, fn: (l: Ledger) => Ledger) {
190 await update($, ledger, fn)
191 if (!local.cityDir || !local.self || local.isClosing) return
192 // One write at a time, each of the newest totals, so the inbox never steps backwards; a stuck write gives up.
193 local.inboxQueue = local.inboxQueue
194 .then(async () => within($, WRITE_TIMEOUT_MS, writeSafe($, 'ledger', `${paths().inbox}/${local.self}.json`, await read($, ledger))))
195 .catch(() => undefined)
196}
197
198async function note($: EngineInterface, text: string | string[]) {
199 const texts = (typeof text === 'string' ? [text] : text).map(t => clean(t, 120)).filter(Boolean)
200 if (!texts.length) return
201 const at = await $.clock.now()
202 await update($, log, lines => [...logLines(lines), ...texts.map(text => ({ at, text }))].slice(-LOG_KEEP))
203}
204
205async function earn($: EngineInterface, fn: (l: Ledger) => Ledger, message: string) {
206 await record($, fn)
207 await note($, message)
208}
209
210/** Runs game bookkeeping after the hook has returned, so prompts and tool results never wait on it. */
211function later($: EngineInterface, work: () => Promise<unknown>) {
212 $.clock.after(0, () => void work().catch(() => undefined))
213}
214
215const twoDigits = (n: number) => String(n).padStart(2, '0')
216const clock = (at: number) => {
217 const d = new Date(at)
218 return `${twoDigits(d.getHours())}:${twoDigits(d.getMinutes())}`
219}
220
221const nameFromSlug = (slug: string) => slug.replace(HASHED, '').replace(/^Users-[^-]+-/, '').replace(/^projects-/, '')
222
223/** Every city saved on this machine, for the map: this one live, the rest as last saved. */
224async function refreshRegion($: EngineInterface) {
225 const live = await read($, neighbours)
226 const dirs = (await $.fs.list(local.citiesDir).catch(() => [])).filter(d => d.kind === 'dir' && !d.isLink && /^[A-Za-z0-9-]{1,160}$/.test(d.name))
227 const moved = new Set(dirs.filter(d => HASHED.test(d.name)).map(d => d.name.replace(HASHED, '')))
228 const candidates = dirs
229 .filter(d => d.name !== local.slug && d.name !== local.legacySlug && (HASHED.test(d.name) || !moved.has(d.name)))
230 .slice(0, REGION_SCAN)
231 const dated = await Promise.all(
232 candidates.map(async d => {
233 const stat = await $.fs.stat(`${local.citiesDir}/${d.name}/city.json`).catch(() => undefined)
234 return { d, at: stat?.kind === 'file' ? stat.mtimeMs : -1, size: stat?.size ?? 0 }
235 }),
236 )
237 const recent = dated
238 .filter(x => x.at >= 0)
239 .sort((a, b) => b.at - a.at)
240 .slice(0, MAX_REGION - 1)
241 const found: District[] = []
242 for (const { d: dir, at, size } of recent) {
243 const cached = local.cityCache.get(dir.name)
244 const c = cached && cached.mtime === at && cached.size === size ? cached.city : sanitizeCity(await readSafe($, `${local.citiesDir}/${dir.name}/city.json`))
245 if (!c) continue
246 local.cityCache.set(dir.name, { mtime: at, size, city: c })
247 const sessions = live.filter(n => n.slug === dir.name)
248 found.push({
249 slug: dir.name,
250 name: c.project ?? nameFromSlug(dir.name),
251 city: c,
252 isHere: false,
253 isBusy: sessions.some(n => n.isBusy),
254 agents: sessions.flatMap(n => n.agents).slice(0, 12),
255 })
256 }
257 found.sort((a, b) => b.city.citizens - a.city.citizens)
258 const mine: District = { slug: local.slug, name: local.project, city: await seen($), isHere: true, isBusy: false, agents: [] }
259 await update($, region, () => [mine, ...found])
260}
261
262async function toggleView($: EngineInterface) {
263 const next = await update($, view, v => (v === 'map' ? 'street' : 'map'))
264 if (next === 'map') await refreshRegion($)
265}
266
267async function purchase($: EngineInterface, id: ItemId) {
268 const attempt = buy(await seen($), id)
269 if (attempt.error !== undefined) {
270 $.ui.toast(attempt.error)
271 return attempt.error
272 }
273 await record($, l => ({ ...l, purchases: [...l.purchases, id] }))
274 const name = CATALOG.find(i => i.id === id)?.name ?? id
275 await note($, `Bought ${name.toLowerCase()}!`)
276 $.ui.toast(`${name} added to the city`)
277 return `Bought ${name}.`
278}
279
280/** The city to start from when this folder has none yet: an older folder's save, the store's backup, or a new one. */
281async function foundingCity($: EngineInterface, now: number) {
282 // The store's backup is keyed by this exact path, so it comes first. The old folder was shared by paths that
283 // differ only in punctuation: only carry it over if it was this project's.
284 const backup = sanitizeCity(await $.store.get(local.storeKey))
285 if (backup) return backup
286 const legacy = sanitizeCity(await readSafe($, `${local.citiesDir}/${local.legacySlug}/city.json`))
287 const isMine = legacy !== undefined && (legacy.project === undefined || legacy.project === local.project)
288 return (isMine ? legacy : undefined) ?? newCity(now)
289}
290
291async function runCity($: EngineInterface, now: number) {
292 if (local.isEnded || !local.self) return
293 if (local.isOffline) {
294 const shared = pruneApplied(tick(applyLedger(await read($, city), local.self, await read($, ledger), now), now), now)
295 await update($, city, () => shared)
296 await $.store.set(local.storeKey, shared)
297 return
298 }
299
300 const raw = (await readSafe($, paths().lease)) as Partial<Lease> | undefined
301 const lease = typeof raw?.id === 'string' && typeof raw.until === 'number' ? { id: raw.id, until: raw.until } : undefined
302 const round = contest(lease, local.self, now)
303 if (round.write) await writeSafe($, 'lease', paths().lease, round.write)
304 local.isMayor = round.isMayor
305 if (!local.isMayor) return
306
307 const exists = await $.fs.exists(paths().city)
308 let shared = exists ? sanitizeCity(await readSafe($, paths().city)) : await foundingCity($, now)
309 if (!shared) {
310 local.corrupt += 1
311 if (local.corrupt < 3) return
312 shared = sanitizeCity(await $.store.get(local.storeKey)) ?? newCity(now)
313 }
314 local.corrupt = 0
315 shared = pruneApplied(shared, now)
316
317 const entries = (await $.fs.list(paths().inbox).catch(() => []))
318 .filter(f => f.kind === 'file' && !f.isLink && SESSION_FILE.test(f.name) && now - f.mtimeMs <= LEDGER_FRESH_MS)
319 .sort((a, b) => b.mtimeMs - a.mtimeMs)
320 .slice(0, MAX_SESSIONS * 2)
321 for (const f of entries) {
322 const session = f.name.slice(0, -5)
323 // At the cap a session we've no record of can't be told from one dropped earlier, so it waits.
324 if (Object.keys(shared.applied ?? {}).length >= MAX_SESSIONS && !shared.applied?.[session]) continue
325 const l = sanitizeLedger(await readSafe($, `${paths().inbox}/${f.name}`))
326 if (l) shared = applyLedger(shared, session, l, now)
327 }
328
329 shared = pruneApplied(tick(shared, now), now)
330 shared = { ...shared, rev: (shared.rev ?? 0) + 1, project: local.project }
331 await writeSafe($, 'city', paths().city, shared)
332 await $.store.set(local.storeKey, shared).catch(() => undefined)
333}
334
335async function panBy($: EngineInterface, by: number, columns: number) {
336 local.pan = nudge(await seen($), columns, await $.clock.now(), local.pan, by, PAN_HOLD_MS)
337}
338
339async function pullCity($: EngineInterface) {
340 const shared = sanitizeCity(await readSafe($, paths().city))
341 if (!shared) return
342 const before = await read($, city)
343 if (local.hasSynced && shared.rev === before.rev) return
344 await update($, city, () => shared)
345 if (!local.hasSynced) {
346 local.hasSynced = true
347 return
348 }
349 const news = milestones(before, shared)
350 const now = await $.clock.now()
351 if (news.length && now - local.lastToast >= TOAST_GAP_MS) {
352 local.lastToast = now
353 $.ui.toast(news.join(' · '))
354 }
355 await note($, [...happenings(before, shared), ...news])
356}
357
358/** This session's presence: just enough for other sessions to draw it, nothing about the work itself. */
359async function announce($: EngineInterface, live: AgentView[]) {
360 const c = await seen($)
361 const presence: Neighbour = {
362 id: local.self,
363 slug: local.slug,
364 project: local.project,
365 isBusy: local.isBusy || live.some(a => a.status === 'running'),
366 tier: tierOf(c),
367 skyline: c.buildings.filter(b => b.kind !== 'park').map(b => b.target),
368 agents: live.map(a => ({ type: a.type, status: a.status })),
369 }
370 if (local.isClosing) return
371 local.presenceWrite = within($, WRITE_TIMEOUT_MS, writeSafe($, 'presence', paths().presence, presence)).catch(() => undefined)
372 await local.presenceWrite
373}
374
375/** The districts as drawn this frame: this city with its live state, the rest as last read. */
376async function districtsNow($: EngineInterface) {
377 const [all, c, mine] = [await read($, region), await seen($), await read($, agents)]
378 return all.map(d => (d.isHere ? { ...d, city: c, isBusy: local.isBusy || mine.some(a => a.status === 'running'), agents: mine } : d))
379}
380
381async function settle($: EngineInterface) {
382 const home = await $.env.get('HOME')
383 const cwd = await $.session.cwd()
384 local.storeKey = `city:${cwd}`
385 local.slug = cityFolderOf(cwd)
386 local.legacySlug = slugOf(cwd)
387 local.project = projectOf(cwd)
388 local.isOffline = !home || !home.startsWith('/') || home === '/'
389 if (!home || local.isOffline) return
390 local.root = `${home.replace(/\/+$/, '')}/.claude/code-city`
391 local.citiesDir = `${local.root}/cities`
392 local.cityDir = `${local.citiesDir}/${local.slug}`
393 local.presenceDir = `${local.root}/presence`
394 if (!(await $.fs.exists(local.root))) {
395 // A "code-city" entry that doesn't exist is a link to nowhere: following it would put our files elsewhere.
396 const claude = await $.fs.list(`${home.replace(/\/+$/, '')}/.claude`).catch(() => [])
397 if (claude.some(f => sameName(f.name, 'code-city'))) {
398 local.isOffline = true
399 return
400 }
401 await $.fs.write(`${local.root}/README.txt`, 'Shared state for the Code City plugin.\n').catch(() => undefined)
402 }
403 const rootStat = await $.fs.stat(local.root, { resolve: true }).catch(() => undefined)
404 local.rootReal = rootStat?.kind === 'dir' ? (rootStat.realPath ?? '') : ''
405 if (!local.rootReal) local.isOffline = true
406}
407
408/** Drops store backups for folders that no longer exist (temporary and deleted projects). */
409async function pruneStore($: EngineInterface) {
410 const keys = (await $.store.keys().catch(() => [] as string[])).filter(k => k.startsWith('city:/')).slice(0, 200)
411 for (const key of keys) if (key !== local.storeKey && !(await $.fs.exists(key.slice(5)).catch(() => true))) await $.store.delete(key).catch(() => undefined)
412}
413
414export const register: Register = (on, options) => {
415 // Off unless the person turns it on: the pane opens only when asked for with /city.
416 const isAutoOpen = options.autoOpen === true
417
418 let mounted: { columns: number; rows: number } | undefined
419
420 on('session.start', async ($, e, next) => {
421 for (const t of local.timers.splice(0)) t.cancel()
422 await $.command.register({ name: 'city', description: 'Watch your code city grow (map, shop, buy <item>)', argumentHint: '[map | shop | buy <item>]' })
423 await settle($)
424 const now = await $.clock.now()
425 const saved = sanitizeCity(await $.store.get(local.storeKey))
426 await update($, city, c => {
427 const kept = sanitizeCity(c)
428 return kept && kept.founded > 0 ? kept : (saved ?? newCity(now))
429 })
430 // State kept across a hot reload was written by older code: check it again before drawing from it.
431 await update($, ledger, l => sanitizeLedger(l) ?? emptyLedger())
432 await update($, log, l => logLines(l))
433 local.self = await update($, self, id => (typeof id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(id) ? id : crypto.randomUUID()))
434 local.isEnded = false
435 local.isClosing = false
436 local.reported.clear()
437 if (!local.isOffline) await pruneStore($)
438
439 const every = (ms: number, work: () => Promise<unknown>) => {
440 let isRunning = false
441 local.timers.push(
442 $.clock.every(ms, () => {
443 if (isRunning || local.isEnded) return
444 isRunning = true
445 void work()
446 .catch(() => undefined)
447 .finally(() => {
448 isRunning = false
449 })
450 }),
451 )
452 }
453
454 every(2000, async () => runCity($, await $.clock.now()))
455 every(1000, async () => pullCity($))
456
457 every(2500, async () => {
458 const now = await $.clock.now()
459 const before = await read($, agents)
460 const listed = await $.agent.list()
461 const live = listed
462 .filter(a => LIVE.includes(a.status))
463 .map(a => ({ id: a.id, type: clean(a.type, 32) || 'agent', description: clean(a.description, 100), status: a.status }))
464 await update($, agents, () => live)
465
466 for (const a of before.filter(a => !live.some(l => l.id === a.id))) {
467 const ok = listed.find(l => l.id === a.id)?.status !== 'failed'
468 await earn($, l => ({ ...l, bricks: l.bricks + (ok ? 15 : 3) }), `${a.type} drone ${ok ? 'landed' : 'crashed'}: ${a.description}${ok ? ' (+15 bricks)' : ''}`)
469 }
470
471 if (local.isOffline) return
472 await announce($, live)
473 const found = await readNeighbours($, now)
474 await update($, neighbours, () => found.others)
475 await update($, sessionsHere, () => found.here + 1)
476 if ((await read($, view)) === 'map') await refreshRegion($)
477 })
478
479 every(120, async () => {
480 if (!mounted) return
481 const now = await $.clock.now()
482 const cells =
483 (await read($, view)) === 'map'
484 ? pack(paintRegion(await districtsNow($), mounted.columns, mounted.rows * 2, now, await read($, zoom)), mounted.columns, mounted.rows)
485 : frame(await seen($), mounted.columns, mounted.rows, now, { agents: await read($, agents), neighbours: await read($, neighbours), pan: local.pan })
486 const res = await $.ui.blit({ requestId: PANE, key: 'skyline', cells, ...mounted })
487 if (res.deny) mounted = undefined
488 })
489
490 if (isAutoOpen) void $.ui.open({ id: PANE, title: 'City', columns: 64, rows: 20 })
491 return next(e)
492 }).catch(($, e, next) => next(e))
493
494 on('session.end', async ($, e, next) => {
495 if (e.reason === 'clear' || e.reason === 'resume') return next(e)
496 for (const t of local.timers.splice(0)) t.cancel()
497 local.isClosing = true
498 try {
499 if (!local.isOffline && local.self) {
500 // Privacy first: wait out any presence write already under way, then blank the file. Every wait is bounded,
501 // since the whole exit shares one short window with other plugins.
502 await within($, 300, local.presenceWrite)
503 await within($, 300, writeSafe($, 'presence', paths().presence, {}))
504 await within($, 300, local.inboxQueue)
505 const left = next.budget.remainingMs
506 if (local.isMayor && left > 1_000) await within($, left - 500, runCity($, await $.clock.now()))
507 if (local.isMayor) await within($, 200, writeSafe($, 'lease', paths().lease, { id: local.self, until: 0 }))
508 }
509 } finally {
510 local.isEnded = true
511 }
512 return next(e)
513 }).catch(($, e, next) => next(e))
514
515 on('command.run', { command: 'city' }, async ($, e) => {
516 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
517 if (verb === 'buy') {
518 const query = clean(rest.join(' '), 40)
519 if (!query) return { text: 'Usage: /city buy <item>. Open /city shop to see what is for sale.' }
520 const item = findItem(query)
521 if (!item) return { text: `Nothing called "${query}" is for sale. Try /city shop.` }
522 return { text: await purchase($, item.id) }
523 }
524 if (verb === 'map') {
525 await toggleView($)
526 await $.ui.open({ id: PANE, title: 'City', columns: 64, rows: 20 })
527 return { text: (await read($, view)) === 'map' ? 'Showing every city on the map.' : 'Back to the street.' }
528 }
529 await update($, isShopOpen, () => verb === 'shop')
530 await $.ui.open({ id: PANE, title: 'City', columns: 64, rows: 20 })
531 const c = await seen($)
532 if (verb === 'shop') return { text: `The shop is open: ${formatTokens(balanceOf(c))} tokens to spend.` }
533 return { text: `${tierOf(c)} of ${Math.floor(c.citizens)} citizens, ${formatTokens(balanceOf(c))} tokens to spend.` }
534 }).catch(($, e, next) => next(e))
535
536 on('turn.start', async ($, e, next) => {
537 local.isBusy = true
538 local.turnTokens = 0
539 return next(e)
540 }).catch(($, e, next) => next(e))
541
542 on('turn.complete', async ($, e, next) => {
543 if (e.agentId === undefined) {
544 local.isBusy = false
545 const used = local.turnTokens
546 local.turnTokens = 0
547 if (used > 0) later($, () => note($, `That turn used ${formatTokens(used)} tokens`))
548 }
549 return next(e)
550 }).catch(($, e, next) => next(e))
551
552 on('turn.step', async function* ($, e, next) {
553 const result = yield* next(e)
554 try {
555 const usage = result.usage
556 if (usage) {
557 const n = weighTokens(usage)
558 local.turnTokens += n
559 later($, () => record($, l => ({ ...l, tokens: l.tokens + n })))
560 }
561 } catch {
562 // Bookkeeping must never disturb the model's reply.
563 }
564 return result
565 })
566
567 on('agent.spawn', async ($, e, next) => {
568 const spawned = await next(e)
569 if (spawned.deny === undefined) {
570 const what = `${clean(e.subagentType, 32)} drone launched: ${clean(e.description, 100)}`
571 later($, () => earn($, l => ({ ...l, citizens: l.citizens + 1 }), what))
572 }
573 return spawned
574 }).catch(($, e, next) => next(e))
575
576 on('prompt.submit', async ($, e, next) => {
577 later($, () => earn($, l => ({ ...l, citizens: l.citizens + 2, bricks: l.bricks + 3, prompts: l.prompts + 1 }), 'New arrivals: +2 citizens'))
578 return next(e)
579 }).catch(($, e, next) => next(e))
580
581 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
582 const ran = await next(e)
583 if (ran.deny === undefined && !ran.isError) {
584 const base = clamp(lines(e.new_string), 2, 40)
585 const file = clean(e.file_path.split('/').pop(), 60)
586 later($, async () => {
587 const n = base * bonus(await seen($))
588 await earn($, l => ({ ...l, bricks: l.bricks + n, edits: l.edits + 1 }), `Edit ${file}: +${Math.round(n)} bricks`)
589 })
590 }
591 return ran
592 }).catch(($, e, next) => next(e))
593
594 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
595 const ran = await next(e)
596 if (ran.deny === undefined && !ran.isError) {
597 const base = clamp(Math.round(lines(e.content) / 2), 5, 60)
598 const file = clean(e.file_path.split('/').pop(), 60)
599 later($, async () => {
600 const n = base * bonus(await seen($))
601 await earn($, l => ({ ...l, bricks: l.bricks + n, edits: l.edits + 1, citizens: l.citizens + 1 }), `Wrote ${file}: +${Math.round(n)} bricks`)
602 })
603 }
604 return ran
605 }).catch(($, e, next) => next(e))
606
607 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
608 const ran = await next(e)
609 if (ran.deny !== undefined) return ran
610 const cmd = e.command
611 const isCommit = /\bgit\s+commit\b/.test(cmd)
612 const isTest = /\b(test|jest|vitest|pytest|mocha|cargo test|go test)\b/.test(cmd)
613 const failed = ran.isError === true
614 later($, async () => {
615 const now = await $.clock.now()
616 if (failed) return earn($, l => ({ ...l, showerUntil: now + 12_000 }), 'A command failed: a brief shower')
617 await earn(
618 $,
619 l => ({
620 ...l,
621 power: l.power + 2,
622 bricks: l.bricks + 1,
623 commands: l.commands + 1,
624 parks: l.parks + (isTest ? 1 : 0),
625 rainbowUntil: isTest ? now + 20_000 : l.rainbowUntil,
626 fireworksUntil: isCommit ? now + 10_000 : l.fireworksUntil,
627 }),
628 isCommit ? 'Commit! Fireworks over the city' : isTest ? 'Tests passed: a rainbow, and a park is planned' : '+2 power',
629 )
630 if (isCommit) $.ui.toast('Fireworks over Code City!')
631 })
632 return ran
633 }).catch(($, e, next) => next(e))
634
635 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
636 const { Box, Text, Button } = $.ui.resolve(e)
637 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : undefined
638 const c = await seen($)
639 const here = await read($, sessionsHere)
640 const lines = logLines(await read($, log))
641 const isShop = await read($, isShopOpen)
642 const isMap = (await read($, view)) === 'map'
643 const districts = isMap ? await districtsNow($) : []
644 const zoomLevel = await read($, zoom)
645 const scene = { agents: await read($, agents), neighbours: await read($, neighbours), pan: local.pan }
646 const now = await $.clock.now()
647 const balance = balanceOf(c)
648 const forSale = CATALOG.filter(i => !owns(c, i.id))
649 const affordable = forSale.filter(i => i.price <= balance).length
650 const goal = forSale.find(i => i.price > balance)
651
652 const width = clamp(e.props.bodyColumns, 20, 240)
653 const columns = width >= 105 ? 3 : width >= 70 ? 2 : 1
654 const built = CATALOG.filter(i => owns(c, i.id))
655 const gridRows = Math.ceil(forSale.length / columns)
656 const panelRows = isShop ? gridRows + 4 + (built.length ? 1 : 0) : 3
657 const shown = scene.agents.slice(0, 3)
658 const legend = shown.length + (scene.agents.length > 3 ? 1 : 0) + (isMap || scene.neighbours.length ? 1 : 0)
659 const logRows = e.props.scroll.bodyRows >= 26 ? 4 : 3
660 const rows = clamp(e.props.scroll.bodyRows - 1 - panelRows - legend - logRows, 6, 30)
661 mounted = { columns: width, rows }
662
663 const building = c.buildings.some(b => b.floors < b.target)
664 const sky = weatherLabel(weatherAt(now, c), skyAt(now).isNight)
665 const stats = `${tierOf(c)} · ${Math.floor(c.citizens)} citizens · ${Math.floor(c.bricks)} bricks · ${Math.floor(c.power)} power · ${c.buildings.length} lots · ${sky}${stationsOf(c) ? ` · ${stationsOf(c)} stations` : ''}${here > 1 ? ` · ${here} sessions here` : ''}`
666 const canPan = Raster !== undefined && !isMap && !isShop
667 const keys = isMap ? 's shop · v street · z zoom' : canPan ? 's shop · v map · a/d scroll' : 's shop · v map'
668 const keyHint = e.props.isFocused ? keys : `click or ctrl+x tab, then ${keys}`
669
670 const progress =
671 affordable > 0 ? (
672 <Text bold color="green">
673 {' '}
674 {affordable} {affordable === 1 ? 'item' : 'items'} ready to buy
675 </Text>
676 ) : goal ? (
677 <Text dimColor>
678 {' '}
679 {formatTokens(goal.price - balance)} more for {goal.name.toLowerCase()}
680 </Text>
681 ) : (
682 <Text dimColor>{' '}everything is built</Text>
683 )
684
685 const toggle = (
686 <Box flexDirection="row" gap={1}>
687 <Text dimColor>{keyHint}</Text>
688 {canPan && <Button key="pan-left" hotkey="a" plain label="◀" onPress={() => panBy($, -PAN_STEP, width)} />}
689 {canPan && <Button key="pan-right" hotkey="d" plain label="▶" onPress={() => panBy($, PAN_STEP, width)} />}
690 <Button
691 key="shop"
692 hotkey="s"
693 label={isShop ? 'Close shop' : 'Open shop'}
694 variant={isShop ? 'secondary' : 'primary'}
695 onPress={() => update($, isShopOpen, open => !open)}
696 />
697 <Button
698 key="view"
699 hotkey="v"
700 label={isMap ? 'Street' : 'Map'}
701 onPress={() => toggleView($)}
702 />
703 {isMap && <Button key="zoom" hotkey="z" label={`Zoom: ${ZOOM_NAMES[zoomLevel] ?? 'mid'}`} onPress={() => update($, zoom, z => (z + 1) % ZOOMS)} />}
704 </Box>
705 )
706
707 const header = (
708 <Box flexDirection="row" justifyContent="space-between">
709 <Text wrap="truncate-end">
710 <Text bold color="yellow">
711 {isShop ? 'City shop · ' : ''}
712 {formatTokens(balance)} tokens
713 </Text>
714 {progress}
715 </Text>
716 {toggle}
717 </Box>
718 )
719
720 const shopItems = forSale.map(item => {
721 const canAfford = balance >= item.price
722 return (
723 <Box flexDirection="row" gap={1} width={`${Math.floor(100 / columns)}%`}>
724 <Button
725 key={`buy-${item.id}`}
726 hotkey={item.hotkey}
727 label={`${item.hotkey}: ${formatTokens(item.price)}`}
728 dimColor={!canAfford}
729 variant={canAfford ? 'primary' : 'secondary'}
730 onPress={() => purchase($, item.id)}
731 />
732 <Text bold={canAfford} dimColor={!canAfford} wrap="truncate-end">
733 {item.name}
734 </Text>
735 </Box>
736 )
737 })
738 const spotlight = forSale.find(i => i.price <= balance) ?? goal
739
740 return (
741 <Box flexDirection="column">
742 <Text bold wrap="truncate-end">
743 {stats}
744 {building ? ' · under construction' : ''}
745 </Text>
746 {Raster ? (
747 <Raster
748 key="skyline"
749 columns={width}
750 rows={rows}
751 cells={isMap ? pack(paintRegion(districts, width, rows * 2, now, zoomLevel), width, rows) : frame(c, width, rows, now, scene)}
752 />
753 ) : (
754 <Text dimColor>The skyline draws in the terminal.</Text>
755 )}
756 {shown.map(a => (
757 <Text wrap="truncate-end">
758 <Text color={hex(agentColor(a.type))}>◆ </Text>
759 <Text bold>{a.type}</Text>
760 <Text dimColor>
761 {' '}
762 {a.status === 'running' ? 'flying' : a.status}: {a.description}
763 </Text>
764 </Text>
765 ))}
766 {scene.agents.length > 3 && <Text dimColor>+{scene.agents.length - 3} more drones</Text>}
767 {isMap && (
768 <Text wrap="truncate-end">
769 <Text dimColor>Cities: </Text>
770 {districts.map((d, i) => (
771 <Text>
772 {i > 0 ? ' · ' : ''}
773 <Text color={hex(agentColor(d.name))} bold={d.isHere}>
774 ⚑ {d.name}
775 </Text>
776 <Text dimColor>
777 {' '}
778 ({tierOf(d.city)}, {Math.floor(d.city.citizens)}
779 {d.isHere ? ', here' : d.isBusy ? ', working' : ''}
780 {d.agents.length ? `, ${d.agents.length} drones` : ''})
781 </Text>
782 </Text>
783 ))}
784 </Text>
785 )}
786 {!isMap && scene.neighbours.length > 0 && (
787 <Text wrap="truncate-end">
788 <Text dimColor>Neighbours: </Text>
789 {scene.neighbours.slice(0, 6).map((n, i) => (
790 <Text>
791 {i > 0 ? ' · ' : ''}
792 <Text color={hex(agentColor(n.project))}>{n.project}</Text>
793 <Text dimColor>
794 {' '}
795 ({n.tier}, {n.isBusy ? 'working' : 'idle'}
796 {n.agents.length ? `, ${n.agents.length} drones` : ''})
797 </Text>
798 </Text>
799 ))}
800 {scene.neighbours.length > 6 && <Text dimColor> · +{scene.neighbours.length - 6} more</Text>}
801 </Text>
802 )}
803 <Box
804 key="wallet"
805 borderStyle="round"
806 borderColor={affordable ? 'green' : 'yellow'}
807 paddingX={1}
808 flexDirection="column"
809 >
810 {header}
811 {isShop && (
812 <Box flexDirection="row" flexWrap="wrap">
813 {shopItems}
814 </Box>
815 )}
816 {isShop && built.length > 0 && (
817 <Text wrap="truncate-end">
818 <Text color="green">✓ Built: </Text>
819 <Text dimColor>{built.map(i => i.name).join(', ')}</Text>
820 </Text>
821 )}
822 {isShop && spotlight && (
823 <Text dimColor wrap="truncate-end">
824 {spotlight.name}: {spotlight.blurb}. Press an item's key to buy it while the pane has the keyboard.
825 </Text>
826 )}
827 </Box>
828 {lines.length === 0 ? (
829 <Text dimColor>Prompt, edit and run commands to grow the city.</Text>
830 ) : (
831 lines.slice(-logRows).map((line, i, recent) => (
832 <Box key={`log-${i}`}>
833 <Text wrap="truncate-end" dimColor={i < recent.length - 1}>
834 <Text dimColor>{clock(line.at)} </Text>
835 {line.text}
836 </Text>
837 </Box>
838 ))
839 )}
840 </Box>
841 )
842 }).catch(($, e) => {
843 const { Text } = $.ui.resolve(e)
844 return <Text dimColor>Code City could not draw this frame; it will try again on the next update.</Text>
845 })
846}
847hooks/city.ts 475 lines1import type { Building, City, Kind, Pan, Scene } from '../types'
2import { drawBuilding } from './buildings'
3import { hash, hashStr, mix, PAVEMENT, STREET } from './pixels'
4import {
5 drawAurora,
6 drawBolt,
7 drawClouds,
8 drawFog,
9 drawPrecipitation,
10 drawRainbow,
11 drawSnowCover,
12 drawStreetWeather,
13 overcastTint,
14 weatherAt,
15} from './weather'
16import { drawPeople } from './people'
17import { itemOf, owns } from './shop'
18import { drawHeavens, drawSkyline, drawStreetscape, drawSwarm } from './specials'
19import { depthOf, drawUnderground, entrancesOf, lengthsOf, SECOND_LINE_AT, stationsOf, SUBWAY_AT, TRAINS_AT } from './underground'
20
21export { hash, hashStr, mix, stationsOf }
22
23export const WORLD = 220
24export const MAX_FLOORS = 14
25const FLOOR_COST = 5
26const LOT_COST = 12
27const DIG_COST = 4
28// Per two-second tick.
29const FACTORY_BURN = 0.1
30const FACTORY_BRICKS = 0.3
31const SWARM_POWER = 1
32const WIDTHS: Record<Kind, [number, number]> = {
33 house: [4, 6],
34 shop: [5, 7],
35 apartment: [6, 8],
36 tower: [5, 9],
37 factory: [8, 10],
38 civic: [9, 11],
39 landmark: [5, 6],
40 park: [6, 10],
41 special: [0, 0],
42}
43
44const TIERS: [number, string][] = [
45 [500, 'Metropolis'],
46 [200, 'City'],
47 [80, 'Town'],
48 [30, 'Village'],
49 [10, 'Hamlet'],
50 [0, 'Campsite'],
51]
52
53const MIX: [number, Partial<Record<Kind, number>>][] = [
54 [200, { house: 12, shop: 20, apartment: 25, tower: 30, civic: 8, landmark: 5 }],
55 [80, { house: 25, shop: 25, apartment: 25, tower: 15, civic: 7, landmark: 3 }],
56 [30, { house: 45, shop: 30, apartment: 18, civic: 7 }],
57 [0, { house: 70, shop: 30 }],
58]
59
60export const tierOf = (c: City) => TIERS.find(([min]) => c.citizens >= min)?.[1] ?? 'Campsite'
61
62export const newCity = (now: number): City => ({
63 founded: now,
64 bricks: 15,
65 citizens: 3,
66 power: 0,
67 parks: 0,
68 edits: 0,
69 prompts: 0,
70 commands: 0,
71 nextId: 2,
72 fireworksUntil: 0,
73 tunnel: 0,
74 tunnel2: 0,
75 buildings: [{ id: 1, x: 0, w: 5, kind: 'house', floors: 0, target: 1, hue: 0 }],
76})
77
78const rightEdge = (c: City) => c.buildings.reduce((m, b) => Math.max(m, b.x + b.w), 0)
79
80function weighted(weights: Partial<Record<Kind, number>>, roll: number): Kind {
81 const entries = Object.entries(weights) as [Kind, number][]
82 const total = entries.reduce((n, [, w]) => n + w, 0)
83 let left = roll * total
84 for (const [kind, w] of entries) if ((left -= w) < 0) return kind
85 return entries[0]?.[0] ?? 'house'
86}
87
88function pickKind(c: City, roll: number, reroll: number): Kind {
89 if (c.parks > 0) return 'park'
90 const factories = c.buildings.filter(b => b.kind === 'factory').length
91 if (c.power >= 10 + factories * 12) return 'factory'
92 const weights = MIX.find(([min]) => c.citizens >= min)?.[1] ?? { house: 1 }
93 const kind = weighted(weights, roll)
94 const last = c.buildings.at(-1)?.kind
95 return kind === last && reroll < 0.6 ? weighted(weights, reroll) : kind
96}
97
98function startingFloors(kind: Kind, roll: number) {
99 const ranges: Record<Kind, [number, number]> = {
100 park: [0, 0],
101 special: [1, 1],
102 house: [1, 3],
103 shop: [1, 4],
104 apartment: [3, 7],
105 tower: [5, 12],
106 factory: [1, 4],
107 civic: [2, 4],
108 landmark: [6, 10],
109 }
110 const [lo, hi] = ranges[kind]
111 return lo + Math.floor(roll * (hi - lo + 1))
112}
113
114const CAPS: Record<Kind, number> = {
115 house: 3,
116 shop: 6,
117 apartment: 10,
118 tower: 13,
119 factory: 5,
120 civic: 5,
121 landmark: 0,
122 park: 0,
123 special: 0,
124}
125
126/** Each building's own height limit: its kind's, scaled between 55% and 125% by its id. */
127export function capOf(b: Pick<Building, 'id' | 'kind'>) {
128 const base = CAPS[b.kind]
129 if (base === 0) return 0
130 return Math.max(1, Math.min(MAX_FLOORS, Math.round(base * (0.55 + 0.7 * hash(b.id, 51)))))
131}
132
133function redevelop(c: City, b: Building, roll: number): Building {
134 const cap = capOf(b)
135 if (b.target < cap) return { ...b, target: Math.min(cap, b.target + 1 + Math.floor(hash(b.id, roll * 1000, 52) * 3)) }
136 const { house: _, landmark: __, ...weights } = MIX.find(([min]) => c.citizens >= min)?.[1] ?? {}
137 const kind = Object.keys(weights).length ? weighted(weights, roll) : 'shop'
138 const target = Math.min(capOf({ id: b.id, kind }), Math.max(b.target + 1, startingFloors(kind, roll)))
139 return { ...b, kind, floors: 0, target }
140}
141
142function dig(c: City): City {
143 const reach = Math.max(rightEdge(c), 0)
144 const [one = 0, two = 0] = lengthsOf(c)
145 if (c.bricks < DIG_COST + FLOOR_COST) return c
146 const step = owns(c, 'express') ? 6 : 3
147 if (c.citizens >= SUBWAY_AT && one < reach) return { ...c, tunnel: Math.min(reach, one + step), bricks: c.bricks - DIG_COST }
148 if (c.citizens >= SECOND_LINE_AT && two < reach) return { ...c, tunnel2: Math.min(reach, two + step), bricks: c.bricks - DIG_COST }
149 return c
150}
151
152/** One idle tick: passive income, factories turning power into bricks, tunnelling, then the next floor or lot. */
153export function tick(c: City, now: number): City {
154 const power = c.power + (owns(c, 'dyson') ? SWARM_POWER : 0)
155 const factories = c.buildings.filter(b => b.kind === 'factory' && b.floors > 0).length
156 const running = Math.min(factories, power / FACTORY_BURN)
157 const income = (Math.min(3, c.citizens * 0.015) + running * FACTORY_BRICKS) * (owns(c, 'cityhall') ? 2 : 1)
158 const next: City = dig({ ...c, power: Math.max(0, power - running * FACTORY_BURN), bricks: c.bricks + income })
159 const site = next.buildings.find(b => b.floors < b.target)
160
161 if (site) {
162 if (next.bricks < FLOOR_COST) return next
163 next.bricks -= FLOOR_COST
164 next.buildings = next.buildings.map(b => (b === site ? { ...b, floors: b.floors + 1 } : b))
165 return next
166 }
167
168 if (next.bricks < LOT_COST) return next
169 const roll = hash(next.nextId, now)
170 const kind = pickKind(next, roll, hash(next.nextId, now, 2))
171 const [lo, hi] = WIDTHS[kind]
172 const w = lo + Math.floor(hash(next.nextId, 7) * (hi - lo + 1))
173 const x = rightEdge(next) + (next.buildings.length ? 1 : 0)
174
175 if (x + w <= WORLD) {
176 next.bricks -= LOT_COST
177 if (kind === 'park') next.parks -= 1
178 if (kind === 'factory') next.power -= 10
179 const cap = capOf({ id: next.nextId, kind })
180 const start = startingFloors(kind, hash(next.nextId, 3))
181 const target = cap ? Math.min(cap, start) : start
182 next.buildings = [...next.buildings, { id: next.nextId, x, w, kind, floors: 0, target, hue: Math.floor(roll * 3) }]
183 next.nextId += 1
184 return next
185 }
186
187 const growable = next.buildings.filter(b => b.target < capOf(b))
188 const pool = growable.length ? growable : next.buildings.filter(b => b.kind === 'house')
189 const ranked = [...pool].sort((a, b) => a.target - b.target)
190 const short = ranked[Math.floor(roll * roll * ranked.length)]
191 if (!short) return next
192 next.bricks -= LOT_COST
193 next.buildings = next.buildings.map(b => (b === short ? redevelop(next, b, roll) : b))
194 return next
195}
196
197const KIND_NAMES: Record<Kind, string> = {
198 house: 'house',
199 shop: 'shop',
200 apartment: 'apartment block',
201 tower: 'tower',
202 factory: 'factory',
203 civic: 'civic hall',
204 landmark: 'landmark',
205 park: 'park',
206 special: 'landmark',
207}
208const nameOf = (b: Building) => (b.special ? (itemOf(b.special)?.name.toLowerCase() ?? 'landmark') : KIND_NAMES[b.kind])
209const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
210const article = (s: string) => `${/^[aeiou]/.test(s) ? 'an' : 'a'} ${s}`
211
212/** What happened on the street between two states of the city, for the log. */
213export function happenings(before: City, after: City): string[] {
214 const old = new Map(before.buildings.map(b => [b.id, b]))
215 const news: string[] = []
216 for (const b of after.buildings) {
217 const was = old.get(b.id)
218 if (!was) news.push(b.kind === 'park' ? 'A new park is laid out' : `Work starts on ${article(nameOf(b))}`)
219 else if (was.kind !== b.kind) news.push(`${capital(article(nameOf(was)))} is redeveloped into ${article(nameOf(b))}`)
220 else if (b.target > was.target) news.push(`${capital(nameOf(b))} gets ${b.target - was.target} more ${b.target - was.target === 1 ? 'floor' : 'floors'}`)
221 else if (was.floors < was.target && b.floors >= b.target && b.kind !== 'park')
222 news.push(b.special ? `${capital(nameOf(b))} opens` : `${capital(nameOf(b))} finished at ${b.floors} ${b.floors === 1 ? 'floor' : 'floors'}`)
223 }
224 return news
225}
226
227/** Announcements earned between two states of the city. */
228export function milestones(before: City, after: City): string[] {
229 const [b1 = 0, b2 = 0] = lengthsOf(before)
230 const [a1 = 0, a2 = 0] = lengthsOf(after)
231 const news: string[] = []
232 if (tierOf(before) !== tierOf(after)) news.push(`Your settlement is now a ${tierOf(after)}!`)
233 if (b1 === 0 && a1 > 0) news.push('Ground broken on the Red Line subway!')
234 if (b1 < TRAINS_AT && a1 >= TRAINS_AT) news.push('The first Red Line train is running!')
235 if (b2 === 0 && a2 > 0) news.push('Digging begins on the Blue Line!')
236 if (b2 < TRAINS_AT && a2 >= TRAINS_AT) news.push('Blue Line trains are in service!')
237 if (stationsOf(after) > stationsOf(before)) news.push(`Station ${stationsOf(after)} opens`)
238 return news
239}
240
241const DAY_MS = 240_000
242const NO_SCENE: Scene = { agents: [], neighbours: [] }
243
244const PAN_MS = 220
245const spanOf = (c: City, W: number) => Math.max(rightEdge(c), ...lengthsOf(c)) - W
246const phaseOf = (span: number, now: number, pan: Pan) => (pan.at + Math.max(0, now - pan.since) / PAN_MS) % (span * 2)
247const across = (span: number, phase: number) => (phase < span ? phase : span * 2 - phase)
248
249/** Pixels the street has panned left: a back-and-forth sweep from phase `pan.at`, held there until `pan.since`. */
250export function scrolled(c: City, W: number, now: number, pan: Pan = { at: 0, since: 0 }) {
251 const span = spanOf(c, W)
252 return span > 0 ? Math.round(across(span, phaseOf(span, now, pan))) : 0
253}
254
255/** Moves the street `by` pixels by hand and holds it there for `holdMs`, keeping the sweep's direction for when it resumes. */
256export function nudge(c: City, W: number, now: number, pan: Pan, by: number, holdMs: number): Pan {
257 const span = spanOf(c, W)
258 if (span <= 0) return pan
259 const phase = phaseOf(span, now, pan)
260 const x = Math.max(0, Math.min(span, Math.round(across(span, phase)) + by))
261 return { at: phase < span ? x : span * 2 - x, since: now + holdMs }
262}
263
264const AGENT_COLORS = [0xff6b6b, 0x4ecdc4, 0xffd93d, 0xc77dff, 0x6bcb77, 0xff9f43, 0x54a0ff]
265export const agentColor = (type: string) => AGENT_COLORS[Math.floor(hashStr(type) * AGENT_COLORS.length)] ?? 0xffffff
266export const hex = (col: number) => `#${col.toString(16).padStart(6, '0')}`
267
268export function skyAt(now: number) {
269 const phase = (now % DAY_MS) / DAY_MS
270 const elev = Math.sin(phase * Math.PI * 2)
271 const light = Math.max(0, Math.min(1, elev * 2.5 + 0.35))
272 return { phase, light, dusk: Math.max(0, 1 - Math.abs(elev) * 4), isNight: light < 0.4 }
273}
274
275/** Paints the scene into a W×H pixel grid of 0xRRGGBB. */
276export function paint(c: City, W: number, H: number, now: number, scene: Scene = NO_SCENE): Uint32Array {
277 const px = new Uint32Array(W * H)
278 const set = (x: number, y: number, col: number) => {
279 if (x >= 0 && x < W && y >= 0 && y < H) px[y * W + x] = col
280 }
281 const get = (x: number, y: number) => px[y * W + x] ?? 0
282 const depth = depthOf(c, H)
283 const ground = H - STREET - depth
284
285 const { phase, light, dusk, isNight } = skyAt(now)
286 const w = weatherAt(now, c)
287 const isDark = isNight || w.overcast > 0.6
288 const sky = { W, ground, now, light, isNight, set, get }
289 const roofTop = new Int16Array(W).fill(H)
290 const solid = (x: number, y: number, col: number) => {
291 set(x, y, col)
292 if (x >= 0 && x < W && y >= 0 && y < (roofTop[x] ?? H)) roofTop[x] = y
293 }
294
295 const top = overcastTint(mix(0x070b1f, 0x4a9fe0, light), w, light)
296 const bottom = overcastTint(mix(mix(0x1a2240, 0xa8dcf5, light), 0xf08a4b, dusk * 0.75), w, light)
297 for (let y = 0; y < ground; y++) {
298 const row = mix(top, bottom, y / ground)
299 for (let x = 0; x < W; x++) set(x, y, row)
300 }
301
302 if (light < 0.6 && w.overcast < 0.6) {
303 for (let y = 0; y < ground - 4; y++)
304 for (let x = 0; x < W; x++)
305 if (hash(x, y, 7) > 0.982) {
306 const twinkle = 0.5 + 0.5 * hash(x, y, Math.floor(now / 700))
307 set(x, y, mix(get(x, y), 0xffffff, (1 - light / 0.6) * twinkle * (1 - w.overcast / 0.6)))
308 }
309 }
310 drawAurora(sky, w)
311
312 const orb = (p: number, col: number) => {
313 const ox = Math.round(p * (W + 6)) - 3
314 const oy = Math.round(ground - Math.sin(p * Math.PI) * (ground - 3))
315 for (let dy = -1; dy <= 1; dy++)
316 for (let dx = -1; dx <= 1; dx++) {
317 const corner = dx !== 0 && dy !== 0
318 if (oy + dy < ground) set(ox + dx, oy + dy, mix(get(ox + dx, oy + dy), col, (corner ? 0.45 : 1) * (1 - w.overcast)))
319 }
320 }
321 if (phase < 0.5) orb(phase * 2, mix(0xffb347, 0xfff3b0, light))
322 else orb((phase - 0.5) * 2, 0xe8ecf5)
323 drawRainbow(sky, w)
324 const moonP = (phase - 0.5) * 2
325 const moon = phase >= 0.5 ? { x: Math.round(moonP * (W + 6)) - 3, y: Math.round(ground - Math.sin(moonP * Math.PI) * (ground - 3)) } : undefined
326 const sunP = phase * 2
327 const sun = phase < 0.5 ? { x: Math.round(sunP * (W + 6)) - 3, y: Math.round(ground - Math.sin(sunP * Math.PI) * (ground - 3)) } : undefined
328 const heavens = { W, set, fx: set, get, shade: (col: number) => col, isNight, now, ground }
329 drawHeavens(heavens, c, light, moon)
330 drawClouds(sky, w)
331 drawSwarm(heavens, c, sun, w.overcast)
332 drawBolt(sky, w)
333
334 const hill = mix(0x141c30, 0x6f9c7a, light)
335 for (let x = 0; x < W; x++) {
336 const hh = Math.round(3 + 2.5 * Math.sin(x / 9) + 1.5 * Math.sin(x / 4.3 + 1))
337 for (let y = ground - hh; y < ground; y++) solid(x, y, mix(hill, get(x, y), 0.25))
338 }
339
340 const districts = scene.neighbours.slice(0, 4)
341 const slot = districts.length ? Math.floor(W / districts.length) : 0
342 const haze = mix(0x2a3550, 0x8fa8c0, light)
343 districts.forEach((n, i) => {
344 const heights = (n.skyline.length ? n.skyline : [1]).slice(0, Math.floor((slot - 4) / 3))
345 const width = heights.length * 3 - 1
346 const x0 = i * slot + Math.floor((slot - width) / 2)
347 const base = ground - Math.max(3, Math.round(ground * 0.28))
348 const glow = agentColor(n.project)
349 for (let x = x0 - 4; x < x0 + width + 4; x++) {
350 const edge = Math.min(x - (x0 - 4), x0 + width + 3 - x)
351 for (let y = base + 1 + Math.max(0, 3 - edge); y < ground; y++) solid(x, y, mix(hill, haze, 0.2))
352 }
353 let peak = { x: x0, y: base }
354 heights.forEach((f, k) => {
355 const h = Math.min(Math.floor(base * 0.6), 2 + Math.round(f * 0.8))
356 for (let x = x0 + k * 3; x < x0 + k * 3 + 2; x++)
357 for (let y = base; y > base - h; y--) {
358 const flicker = hash(n.id.length, x, y, Math.floor(now / 350))
359 const isLit = n.isBusy ? flicker > 0.55 : isDark && hash(x, y) > 0.8
360 solid(x, y, isLit ? mix(0xffd27a, haze, n.isBusy ? 0 : 0.5) : haze)
361 }
362 if (base - h < peak.y) peak = { x: x0 + k * 3, y: base - h }
363 })
364 const isBlink = !n.isBusy || Math.floor(now / 500) % 2 === 0
365 set(peak.x, peak.y - 1, isBlink ? glow : haze)
366 n.agents.forEach((a, k) => {
367 const ax = x0 + Math.round((hash(k, 11) * width + now / 400) % Math.max(1, width))
368 const ay = peak.y - 3 - Math.round(hash(k, 12) * 3 + Math.sin(now / 300 + k))
369 if (ay >= 0) set(ax, ay, Math.floor(now / 250 + k) % 2 ? agentColor(a.type) : 0xffffff)
370 })
371 })
372
373 const used = Math.max(rightEdge(c), ...lengthsOf(c))
374 const off = used <= W ? Math.floor((W - used) / 2) : -scrolled(c, W, now, scene.pan)
375
376 const shade = (col: number) => mix(col, 0x0a0d18, (isNight ? 0.55 : (1 - light) * 0.4) + w.overcast * 0.2)
377 const cv = { W, set: solid, fx: set, get, shade, isNight: isDark, now, ground }
378 for (const b of c.buildings) drawBuilding(cv, b, b.x + off)
379 drawSnowCover(sky, w, roofTop, shade)
380 drawSkyline(cv, c, off)
381
382 scene.agents.forEach((a, k) => {
383 const isWorking = a.status === 'running' || a.status === 'pending'
384 const seed = hashStr(a.id)
385 const span = Math.max(1, W - 4)
386 const travel = (seed * span * 2 + (now / 1000) * (isWorking ? 9 : 0.6)) % (span * 2)
387 const x = 2 + Math.round(travel < span ? travel : span * 2 - travel)
388 const y = 2 + Math.round(hash(k, 21) * (ground / 2.5) + Math.sin(now / 280 + k) * (isWorking ? 1 : 0.5))
389 const col = agentColor(a.type)
390 const rotor = Math.floor(now / 120) % 2 ? 0xd0d4dc : 0x8a8f98
391 set(x - 1, y, rotor)
392 set(x + 1, y, rotor)
393 set(x, y, isWorking && Math.floor(now / 300) % 2 ? 0xffffff : col)
394 if (isWorking) set(x, y + 1, 0x9a5a2a)
395 })
396
397 const walk = shade(0x8a8f98)
398 for (let x = 0; x < W; x++) {
399 set(x, ground, x % 4 === 0 ? mix(walk, 0x000000, 0.12) : mix(walk, 0xffffff, 0.06))
400 set(x, ground + 1, walk)
401 set(x, ground + 2, shade(0x6a6f78))
402 set(x, ground + PAVEMENT, 0x2b2d33)
403 set(x, ground + PAVEMENT + 1, 0x24262b)
404 }
405 drawStreetWeather(sky, w)
406 drawStreetscape(cv, c, off, w.season)
407 drawPeople({ W, ground, now, isNight, shade, set }, c, w, depth > 0 ? entrancesOf(c) : [], off)
408 if (depth > 0) drawUnderground(cv, c, off, depth, rightEdge(c))
409 const cars = Math.min(14, 1 + Math.floor(c.citizens / 12))
410 for (let i = 0; i < cars; i++) {
411 const right = i % 2 === 0
412 const speed = 6 + hash(i, 3) * 10
413 const loop = W + 8
414 const pos = (hash(i, 9) * loop + (now / 1000) * speed) % loop
415 const x = Math.round(right ? pos - 4 : W + 3 - pos)
416 const y = right ? ground + PAVEMENT : ground + PAVEMENT + 1
417 const body = [0xd94040, 0x3f7fd9, 0xf2d24a, 0xf2f2f2, 0x40b070][Math.floor(hash(i, 5) * 5)] ?? 0xd94040
418 const front = isDark ? 0xfff4b0 : body
419 const back = isDark ? 0xff3030 : body
420 set(x, y, right ? back : front)
421 set(x + 1, y, body)
422 set(x + 2, y, right ? front : back)
423 }
424 drawFog(sky, w)
425 drawPrecipitation(sky, w)
426
427 if (now < c.fireworksUntil) {
428 for (let k = 0; k < 3; k++) {
429 const slot = Math.floor(now / 1400) + k * 31
430 const age = ((now + k * 450) % 1400) / 1400
431 const cx = Math.round(hash(slot, 1) * W)
432 const cy = 2 + Math.round(hash(slot, 2) * (ground / 2))
433 const col = [0xff5a5a, 0x5affc8, 0xffe14a, 0xc86bff][Math.floor(hash(slot, 3) * 4)] ?? 0xff5a5a
434 const r = 1 + age * 6
435 for (let a = 0; a < 8; a++) {
436 const ang = (a / 8) * Math.PI * 2
437 const fx = Math.round(cx + Math.cos(ang) * r)
438 const fy = Math.round(cy + Math.sin(ang) * r * 0.6)
439 if (fy < ground) set(fx, fy, mix(col, get(Math.max(0, Math.min(W - 1, fx)), Math.max(0, fy)), age * age))
440 }
441 }
442 }
443
444 return px
445}
446
447const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
448function base64(bytes: Uint8Array) {
449 let out = ''
450 for (let i = 0; i < bytes.length; i += 3) {
451 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
452 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
453 out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
454 out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
455 }
456 return out
457}
458
459export function frame(c: City, columns: number, rows: number, now: number, scene: Scene = NO_SCENE) {
460 return pack(paint(c, columns, rows * 2, now, scene), columns, rows)
461}
462
463/** Packs two pixel rows per terminal cell with an upper-half block. */
464export function pack(px: Uint32Array, columns: number, rows: number) {
465 const words = new Uint32Array(columns * rows * 3)
466 for (let r = 0; r < rows; r++)
467 for (let x = 0; x < columns; x++) {
468 const i = (r * columns + x) * 3
469 words[i] = 0x2580
470 words[i + 1] = px[2 * r * columns + x] ?? 0
471 words[i + 2] = px[(2 * r + 1) * columns + x] ?? 0
472 }
473 return base64(new Uint8Array(words.buffer))
474}
475hooks/iso.ts 343 lines1import type { Building, City, District, Kind } from '../types'
2import { agentColor, skyAt } from './city'
3import { hash, mix } from './pixels'
4import { drawFog, drawPrecipitation, weatherAt } from './weather'
5
6type Canvas = {
7 W: number
8 H: number
9 set: (x: number, y: number, col: number) => void
10 get: (x: number, y: number) => number
11 shade: (col: number) => number
12 now: number
13 isDark: boolean
14 snow: number
15}
16
17const FACE: Record<Kind, number[]> = {
18 house: [0xd9b38c, 0xe0c9a6, 0xa9c4d9, 0xe6b8b8],
19 shop: [0x6d8fb3, 0xb3706d, 0x7fa86a, 0xd9a441],
20 apartment: [0xa0522d, 0xb5651d, 0x9c6b5a],
21 tower: [0x55657a, 0x46536a, 0x67788f, 0x3d5a6b],
22 factory: [0x7a6a5a, 0x6b6460],
23 civic: [0xeae2d0, 0xd8d0c0],
24 landmark: [0xc8b07a, 0xb08060],
25 park: [0x4f9a4a],
26 special: [0xb8c0cc],
27}
28const ROOF: Record<Kind, number> = {
29 house: 0xa3442f,
30 shop: 0x4a4a52,
31 apartment: 0x5a4a40,
32 tower: 0x39465a,
33 factory: 0x5a4e44,
34 civic: 0x5fa89a,
35 landmark: 0xd4af37,
36 park: 0x3f8a3a,
37 special: 0x9aa4b2,
38}
39const SPECIAL: Partial<Record<string, { face: number; roof: number; floors: number }>> = {
40 plaza: { face: 0xc8c0b0, roof: 0xd8d0c0, floors: 0 },
41 ferris: { face: 0x9aa4b2, roof: 0x9aa4b2, floors: 1 },
42 stadium: { face: 0x8a909c, roof: 0x4f9a4a, floors: 2 },
43 needle: { face: 0xc8ccd4, roof: 0xd8dce4, floors: 16 },
44 rocket: { face: 0xf2f2f2, roof: 0xd94040, floors: 7 },
45 biodome: { face: 0x8fd8e8, roof: 0xbfeef6, floors: 3 },
46 supertall: { face: 0x5a6a80, roof: 0xd8dce4, floors: 22 },
47 elevator: { face: 0x8a96a8, roof: 0xe8ecf0, floors: 2 },
48 lighthouse: { face: 0xf2f2f2, roof: 0xd94040, floors: 8 },
49 turbine: { face: 0xe8ecf0, roof: 0xf2f2f2, floors: 9 },
50 castle: { face: 0xa8a294, roof: 0x8a8478, floors: 4 },
51 pyramid: { face: 0x9fc8e8, roof: 0xbfe0f4, floors: 5 },
52}
53const SLAB = 2
54const TUCK = 3
55
56const pick = (list: number[] | undefined, roll: number) => list?.[Math.floor(roll * list.length)] ?? 0x888888
57
58/** A 2:1 isometric prism: a diamond footprint `width` wide whose top vertex is (sx, sy), raised `h` pixels. */
59function prism(cv: Canvas, sx: number, sy: number, width: number, h: number, left: number, right: number, top: number) {
60 const half = width / 2
61 for (let x = sx - half; x < sx + half; x++) {
62 const d = x < sx ? sx - 1 - x : x - sx
63 const yTop = sy + Math.floor(d / 2)
64 const yBot = sy + half - 1 - Math.floor(d / 2)
65 for (let y = yTop - h; y < yBot - h; y++) cv.set(x, y, top)
66 for (let y = Math.max(yBot - h, yTop - h); y <= yBot; y++) cv.set(x, y, x < sx ? left : right)
67 }
68}
69
70function windowsOn(cv: Canvas, b: Building, sx: number, sy: number, width: number, h: number, isGlass: boolean) {
71 const half = width / 2
72 for (let x = sx - half + 1; x < sx + half - 1; x++) {
73 if (!isGlass && x % 2 !== 0) continue
74 const d = x < sx ? sx - 1 - x : x - sx
75 const yBot = sy + half - 1 - Math.floor(d / 2)
76 for (let y = yBot - h + 1; y < yBot - 1; y += 2) {
77 const lit = cv.isDark
78 ? hash(b.id, x - sx, y - sy, Math.floor(cv.now / 9000)) > 0.45
79 ? 0xffd27a
80 : 0x262b3a
81 : mix(0x9fd3f5, 0xffffff, hash(b.id, x, y) * 0.3)
82 cv.set(x, y, lit)
83 }
84 }
85}
86
87function drawBuilding(cv: Canvas, b: Building, sx: number, sy: number, T: number) {
88 const { shade, now } = cv
89 const roll = hash(b.id, 100)
90 const perFloor = T / 4
91 const snowy = (col: number) => mix(col, 0xf4f8ff, cv.snow * 0.85)
92 const footprint = (b.kind === 'house' || b.kind === 'landmark') && T >= 8 ? T - 4 : T
93 const fx = sx
94 const fy = sy + (T - footprint) / 4
95
96 if (b.kind === 'park') {
97 prism(cv, sx, sy, T, 0, shade(0x3f7a3a), shade(0x4f9a4a), shade(snowy(0x4f9a4a)))
98 const tree = shade(snowy(hash(b.id, 7) > 0.5 ? 0x2f7d3a : 0x1f5f3a))
99 cv.set(sx - 1, sy + T / 4 - 1, tree)
100 cv.set(sx - 1, sy + T / 4 - 2, tree)
101 if (T >= 6) cv.set(sx, sy + T / 4 - 1, tree)
102 return
103 }
104
105 const special = b.special ? SPECIAL[b.special] : undefined
106 const face = special?.face ?? pick(FACE[b.kind], roll)
107 const roofCol = special?.roof ?? ROOF[b.kind]
108 const floors = special ? (b.floors < b.target ? b.floors : special.floors) : b.floors
109 const h = Math.min(Math.round(cv.H * 0.7), Math.max(b.floors > 0 ? 1 : 0, Math.round(floors * perFloor)))
110 prism(cv, fx, fy, footprint, h, shade(mix(face, 0x000000, 0.28)), shade(face), shade(snowy(roofCol)))
111 if (b.kind !== 'special' && h >= 2) windowsOn(cv, b, fx, fy, footprint, h, b.kind === 'tower')
112
113 const peak = fy - h
114 if (b.kind === 'house' && h > 0) {
115 cv.set(fx - 1, peak - 1, shade(snowy(roofCol)))
116 cv.set(fx, peak - 1, shade(snowy(roofCol)))
117 } else if (b.kind === 'tower' && h > 0) {
118 cv.set(fx, peak - 1, shade(0x9aa4b2))
119 cv.set(fx, peak - 2, cv.isDark && Math.floor(now / 800) % 2 ? 0xff3030 : shade(0x9aa4b2))
120 } else if (b.kind === 'factory' && h > 0) {
121 for (let y = peak; y > peak - 3; y--) cv.set(fx + 1, y, shade(0x8a3b2b))
122 const rise = (now / 400) % 5
123 cv.set(fx + 1 + Math.round(rise / 2), Math.round(peak - 3 - rise), mix(cv.get(Math.max(0, fx + 1), Math.max(0, peak - 3)), 0xc8c8c8, 0.6))
124 } else if (b.special === 'ferris') {
125 const r = Math.max(2, T / 2)
126 const cy = peak - r
127 for (let a = 0; a < 16; a++) {
128 const ang = (a / 16) * Math.PI * 2 + now / 6000
129 const col = cv.isDark && a % 2 ? [0xff4fd8, 0x4ff0ff, 0xfff04f][a % 3]! : shade(0xe8e8e8)
130 cv.set(Math.round(fx + Math.cos(ang) * r), Math.round(cy + Math.sin(ang) * r * 0.9), col)
131 }
132 } else if (b.special === 'elevator') {
133 for (let y = peak - 1; y >= 0; y--) cv.set(fx, y, mix(cv.get(fx, y), 0xe8ecf0, 0.7))
134 const trip = (now / 1000) % 40
135 cv.set(fx, Math.round(peak - 2 - (trip < 20 ? trip / 20 : 2 - trip / 20) * (peak - 3)), 0x7fd8ff)
136 } else if (b.special === 'rocket') {
137 cv.set(fx, peak - 1, shade(0xd94040))
138 } else if (b.special === 'supertall' || b.special === 'needle') {
139 cv.set(fx, peak - 1, shade(0xc8ccd4))
140 cv.set(fx, peak - 2, cv.isDark && Math.floor(now / 700) % 2 ? 0xff3030 : shade(0xc8ccd4))
141 if (b.special === 'supertall' && cv.isDark) cv.set(fx - 1, peak, [0xff4fd8, 0x4ff0ff, 0xfff04f][Math.floor(now / 1500) % 3]!)
142 }
143
144 if (b.floors < b.target) {
145 const top = fy - Math.round(b.target * perFloor) - 2
146 const mast = fx + footprint / 2 - 1
147 for (let y = fy + T / 4; y >= top; y--) cv.set(mast, y, 0xf2b33d)
148 for (let x = mast - T / 2; x <= mast + 1; x++) cv.set(x, top, 0xf2b33d)
149 cv.set(mast - Math.floor(T / 4), top + 1 + Math.round((Math.sin(now / 700) + 1) * 1.5), 0x9a5a2a)
150 }
151}
152
153/** Plot positions inside the ring road, nearest the back corner first. */
154function plotsFor(n: number) {
155 const m = Math.max(2, Math.ceil(Math.sqrt(n)))
156 const plots: { gx: number; gy: number }[] = []
157 for (let gy = 0; gy < m; gy++) for (let gx = 0; gx < m; gx++) plots.push({ gx: gx + 1, gy: gy + 1 })
158 return { size: m + 2, plots }
159}
160
161function ringTile(size: number, i: number) {
162 const side = size - 1
163 const k = ((i % (side * 4)) + side * 4) % (side * 4)
164 if (k < side) return { gx: k, gy: 0 }
165 if (k < side * 2) return { gx: side, gy: k - side }
166 if (k < side * 3) return { gx: side - (k - side * 2), gy: side }
167 return { gx: 0, gy: side - (k - side * 3) }
168}
169
170const tallest = (c: City, T: number) =>
171 c.buildings.reduce((m, b) => Math.max(m, Math.round(Math.max(b.target, (b.special && SPECIAL[b.special]?.floors) || 0) * (T / 4))), 0)
172
173type Placed = { d: District; size: number; left: number; baseline: number; row: number; T: number }
174
175/** Tile widths from nearest to farthest; the closest that fits is zoom 0. */
176export const TILES = [8, 6, 4, 2]
177export const ZOOMS = 3
178
179/** Islands in rows at tile width T: the first city front and centre, the rest in rows behind. */
180function arrange(districts: District[], sizes: number[], W: number, H: number, T: number) {
181 const gap = T
182 const rows: number[][] = []
183 sizes.forEach((size, i) => {
184 const row = rows.at(-1)
185 const width = row ? row.reduce((n, k) => n + sizes[k]! * T + gap, 0) + size * T : Infinity
186 if (row && width <= W - 2) row.push(i)
187 else rows.push([i])
188 })
189 const depth = (row: number[]) => Math.max(...row.map(k => (sizes[k]! * T) / 2))
190 const widest = Math.max(0, ...rows.map(row => row.reduce((n, k) => n + sizes[k]! * T, 0) + gap * (row.length - 1)))
191 const back = rows.at(-1) ?? []
192 const headroom = Math.max(0, ...back.map(k => tallest(districts[k]!.city, T))) + 2
193 const height = rows.reduce((n, row) => n + depth(row) - TUCK, TUCK) + SLAB + headroom
194
195 const placed: Placed[] = []
196 let baseline = H - 2 - Math.max(0, Math.floor((H - height) / 2))
197 rows.forEach((row, r) => {
198 const width = row.reduce((n, k) => n + sizes[k]! * T, 0) + gap * (row.length - 1)
199 let left = Math.max(1, Math.floor((W - width) / 2))
200 for (const k of row) {
201 placed.push({ d: districts[k]!, size: sizes[k]!, left, baseline, row: r, T })
202 left += sizes[k]! * T + gap
203 }
204 baseline -= depth(row) - TUCK
205 })
206 return { placed, T, width: widest, fits: widest <= W - 2 && height <= H }
207}
208
209/** The map at a zoom level: 0 is the largest tile that fits, each step one size smaller. */
210export function layout(districts: District[], W: number, H: number, zoom = 0) {
211 const sizes = districts.map(d => plotsFor(d.city.buildings.length).size)
212 const near = TILES.slice(0, -1).findIndex(T => arrange(districts, sizes, W, H, T).fits)
213 const fit = near === -1 ? TILES.length - 2 : near
214 const T = TILES[Math.min(TILES.length - 1, fit + Math.max(0, zoom))]!
215 return arrange(districts, sizes, W, H, T)
216}
217
218/** The whole region: every city as an island in a shared sea. */
219export function paintRegion(districts: District[], W: number, H: number, now: number, zoom = 0): Uint32Array {
220 const px = new Uint32Array(W * H)
221 const set = (x: number, y: number, col: number) => {
222 const [xi, yi] = [Math.floor(x), Math.floor(y)]
223 if (xi >= 0 && xi < W && yi >= 0 && yi < H) px[yi * W + xi] = col
224 }
225 const get = (x: number, y: number) => px[Math.floor(y) * W + Math.floor(x)] ?? 0
226 const here = districts.find(d => d.isHere) ?? districts[0]
227 const { light, isNight } = skyAt(now)
228 const w = here ? weatherAt(now, here.city) : undefined
229 const overcast = w?.overcast ?? 0
230 const isDark = isNight || overcast > 0.6
231 const shade = (col: number) => mix(col, 0x0a0d18, (isNight ? 0.55 : (1 - light) * 0.4) + overcast * 0.2)
232 const cv: Canvas = { W, H, set, get, shade, now, isDark, snow: w?.snow ?? 0 }
233
234 const shallow = shade(mix(0x2f6f9f, 0x4f9fd0, light))
235 const deep = shade(mix(0x123050, 0x2a6aa0, light))
236 for (let y = 0; y < H; y++)
237 for (let x = 0; x < W; x++) {
238 const base = mix(deep, shallow, y / H)
239 set(x, y, hash(x >> 1, y, Math.floor(now / 700)) > 0.94 ? mix(base, 0xffffff, 0.2 + light * 0.2) : base)
240 }
241
242 const { placed, T, width } = layout(districts, W, H, zoom)
243 let pan = 0
244 if (width > W - 2) {
245 const span = width - W + 4
246 const t = (now / 250) % (span * 2)
247 pan = Math.round(t < span ? t : span * 2 - t)
248 }
249 const anchors = placed
250 .map(p => {
251 const full = p.size * T
252 return { p, sx: p.left - pan + full / 2, sy: p.baseline - full / 2 + 1, full }
253 })
254 .sort((a, b) => b.p.row - a.p.row || a.p.left - b.p.left)
255
256 for (let i = 1; i < anchors.length; i++) {
257 const a = anchors[i - 1]!
258 const b = anchors[i]!
259 if (a.p.row !== b.p.row) continue
260 const from = { x: a.sx + a.full / 2, y: a.sy + a.full / 4 - SLAB }
261 const to = { x: b.sx - b.full / 2, y: b.sy + b.full / 4 - SLAB }
262 for (let x = from.x; x <= to.x; x++) {
263 const y = Math.round(from.y + ((to.y - from.y) * (x - from.x)) / Math.max(1, to.x - from.x))
264 set(x, y, shade(0x8a8f98))
265 set(x, y + 1, shade(0x5a5f68))
266 }
267 }
268
269 for (const { p, sx, sy, full } of anchors) {
270 const { d, size } = p
271 prism(cv, sx, sy, full, SLAB, shade(0x6a4a30), shade(0x8a6040), shade(0x6f8f5a))
272 const tileAt = (gx: number, gy: number) => ({ x: sx + ((gx - gy) * T) / 2, y: sy - SLAB + ((gx + gy) * T) / 4 })
273
274 for (let gy = 0; gy < size; gy++)
275 for (let gx = 0; gx < size; gx++) {
276 const isRoad = gx === 0 || gy === 0 || gx === size - 1 || gy === size - 1
277 if (!isRoad) continue
278 const t = tileAt(gx, gy)
279 prism(cv, t.x, t.y, T, 0, shade(0x3a3c44), shade(0x3a3c44), shade(mix(0x3a3c44, 0xf4f8ff, cv.snow * 0.5)))
280 }
281
282 const { plots } = plotsFor(d.city.buildings.length)
283 const order = d.city.buildings
284 .map((b, i) => ({ b, plot: plots[i]! }))
285 .sort((a, b) => a.plot.gx + a.plot.gy - (b.plot.gx + b.plot.gy) || a.plot.gx - b.plot.gx)
286 for (const { b, plot } of order) {
287 const t = tileAt(plot.gx, plot.gy)
288 drawBuilding(cv, b, t.x, t.y, T)
289 }
290
291 const ringLen = (size - 1) * 4
292 const cars = Math.min(6, 1 + Math.floor(d.city.citizens / 20))
293 for (let k = 0; k < cars; k++) {
294 const pos = (hash(k, 9) * ringLen + (now / 1000) * (0.8 + hash(k, 3))) % ringLen
295 const tile = ringTile(size, Math.floor(pos))
296 const t = tileAt(tile.gx, tile.gy)
297 const body = [0xd94040, 0x3f7fd9, 0xf2d24a, 0xf2f2f2][k % 4]!
298 set(t.x - 1, t.y + T / 4 - 1, isDark ? 0xfff4b0 : body)
299 }
300
301 const glow = agentColor(d.name)
302 const flagX = sx - full / 2 + 1
303 const flagY = sy + full / 4 - SLAB - 1
304 for (let y = flagY; y > flagY - 4; y--) set(flagX, y, shade(0xd8dce4))
305 const isBlink = !d.isBusy || Math.floor(now / 500) % 2 === 0
306 set(flagX + 1, flagY - 3, isBlink ? glow : shade(glow))
307 set(flagX + 2, flagY - 3 + (Math.floor(now / 600) % 2), isBlink ? glow : shade(glow))
308 if (d.isHere) set(flagX, flagY - 4, 0xffffff)
309
310 d.agents.forEach((a, k) => {
311 const span = Math.max(2, full - 4)
312 const travel = (hash(k, 31) * span * 2 + (now / 1000) * (a.status === 'running' ? 6 : 0.5)) % (span * 2)
313 const x = Math.round(sx - full / 2 + 2 + (travel < span ? travel : span * 2 - travel))
314 const y = Math.round(sy - SLAB - 6 - hash(k, 32) * 4 + Math.sin(now / 280 + k))
315 set(x - 1, y, 0xb0b8c4)
316 set(x + 1, y, 0xb0b8c4)
317 set(x, y, a.status === 'running' && Math.floor(now / 300) % 2 ? 0xffffff : agentColor(a.type))
318 })
319
320 if (now < d.city.fireworksUntil)
321 for (let k = 0; k < 2; k++) {
322 const age = ((now + k * 600) % 1400) / 1400
323 const slot = Math.floor((now + k * 600) / 1400) + k * 17
324 const cx = Math.round(sx - full / 4 + hash(slot, 1) * (full / 2))
325 const cy = Math.round(4 + hash(slot, 2) * 6)
326 const col = [0xff5a5a, 0x5affc8, 0xffe14a, 0xc86bff][Math.floor(hash(slot, 3) * 4)]!
327 for (let a = 0; a < 8; a++) {
328 const ang = (a / 8) * Math.PI * 2
329 const fx = Math.round(cx + Math.cos(ang) * (1 + age * 4))
330 const fy = Math.round(cy + Math.sin(ang) * (1 + age * 4) * 0.6)
331 set(fx, fy, mix(col, get(Math.max(0, Math.min(W - 1, fx)), Math.max(0, Math.min(H - 1, fy))), age * age))
332 }
333 }
334 }
335
336 if (w) {
337 const sky = { W, ground: H, now, light, isNight, set, get }
338 drawFog(sky, w)
339 drawPrecipitation(sky, w)
340 }
341 return px
342}
343hooks/safe.ts 188 lines1import type { Building, City, ItemId, Kind, Ledger, Neighbour, NeighbourAgent } from '../types'
2import { MAX_FLOORS, WORLD } from './city'
3import { CATALOG, MAX_BUILDINGS } from './shop'
4import { MAX_TUNNEL } from './underground'
5
6const KINDS: Kind[] = ['house', 'shop', 'apartment', 'tower', 'factory', 'civic', 'landmark', 'park', 'special']
7const ITEMS = new Set<string>(CATALOG.map(i => i.id))
8const LANDMARK_ITEMS = new Set<string>(CATALOG.filter(i => i.kind === 'landmark').map(i => i.id))
9const STATUSES = ['pending', 'running', 'waiting', 'idle']
10export { MAX_BUILDINGS }
11export const MAX_SESSIONS = 500
12const MAX_PURCHASES = 1_000
13const BIG = 1e15
14
15const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
16
17// Control characters, line/paragraph separators, lone surrogate halves, bidi overrides and isolates,
18// invisible spaces and fillers, and other format characters a name has no use for. ZWJ and ZWNJ stay,
19// since emoji and some scripts need them; tag characters stay only inside a flag (they follow U+1F3F4).
20const UNSAFE =
21 /[\p{Cc}\p{Cs}\p{Zl}\p{Zp}\u00AD\u0600-\u0605\u061C\u06DD\u070F\u08E2\u115F\u1160\u180B-\u180F\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\u2800\u3164\uFEFF\uFFA0\uFFF9-\uFFFB\u{110BD}\u{13430}-\u{1343F}\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}]/gu
22const LOOSE_TAGS = /(?<!\u{1F3F4}[\u{E0020}-\u{E007E}]*)[\u{E0000}-\u{E007F}]/gu
23const STACKED_MARKS = /(\p{M}{4})\p{M}+/gu
24
25const graphemes = (text: string): string[] =>
26 typeof Intl !== 'undefined' && 'Segmenter' in Intl
27 ? Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), s => s.segment)
28 : Array.from(text)
29
30/** Text from outside the plugin: unsafe characters removed, cut to `max` whole characters. */
31export function clean(value: unknown, max = 64): string {
32 if (typeof value !== 'string') return ''
33 const safe = value.slice(0, max * 8).replace(UNSAFE, '').replace(LOOSE_TAGS, '').replace(STACKED_MARKS, '$1')
34 const parts = graphemes(safe)
35 return (parts.length > max ? parts.slice(0, max).join('') : safe).trim()
36}
37
38/** Every file the plugin writes carries this, so a write never lands on a file that isn't ours. */
39export const MARKER = 'codeCity'
40export type FileKind = 'city' | 'ledger' | 'lease' | 'presence'
41export const stamp = (kind: FileKind, data: object) => JSON.stringify({ [MARKER]: `${kind}/1`, ...data })
42
43const LEGACY_KEYS: Record<FileKind, string[]> = {
44 city: [],
45 ledger: ['tokens', 'bricks', 'citizens', 'power', 'parks', 'edits', 'prompts', 'commands', 'fireworksUntil', 'rainbowUntil', 'showerUntil', 'purchases'],
46 lease: ['id', 'until'],
47 presence: ['id', 'slug', 'project', 'cwd', 'isBusy', 'tier', 'skyline', 'agents'],
48}
49
50/**
51 * Whether parsed file contents are ours of this kind: stamped with the marker, or written before the marker
52 * existed and holding exactly our fields (a city must have its buildings list).
53 */
54export function isOurFile(kind: FileKind, raw: unknown): boolean {
55 if (!isRecord(raw)) return false
56 if (raw[MARKER] !== undefined) return raw[MARKER] === `${kind}/1`
57 const keys = Object.keys(raw)
58 if (kind === 'city') return Array.isArray(raw.buildings)
59 return keys.length > 0 && keys.every(k => LEGACY_KEYS[kind].includes(k))
60}
61
62/** A finite number within [lo, hi], or the fallback. */
63export function num(value: unknown, lo: number, hi: number, fallback = lo): number {
64 return typeof value === 'number' && Number.isFinite(value) ? Math.min(hi, Math.max(lo, value)) : fallback
65}
66
67const whole = (value: unknown, lo: number, hi: number, fallback = lo) => Math.round(num(value, lo, hi, fallback))
68
69const itemIds = (value: unknown, allowed: Set<string>): ItemId[] => [...new Set(itemList(value, allowed))]
70
71/** Item ids in order, repeats kept: a ledger's purchases are counted by position, so a retry must survive. */
72const itemList = (value: unknown, allowed: Set<string>): ItemId[] =>
73 Array.isArray(value) ? value.slice(0, MAX_PURCHASES).filter((v): v is ItemId => typeof v === 'string' && allowed.has(v)) : []
74
75function building(raw: unknown): Building | undefined {
76 if (!isRecord(raw)) return undefined
77 const kind = KINDS.includes(raw.kind as Kind) ? (raw.kind as Kind) : 'house'
78 const special = typeof raw.special === 'string' && LANDMARK_ITEMS.has(raw.special) ? (raw.special as ItemId) : undefined
79 if (kind === 'special' && !special) return undefined
80 return {
81 id: whole(raw.id, 0, 1e9),
82 x: whole(raw.x, 0, WORLD * 4),
83 w: whole(raw.w, 1, 24, 5),
84 kind,
85 floors: whole(raw.floors, 0, MAX_FLOORS),
86 target: whole(raw.target, 0, MAX_FLOORS),
87 hue: whole(raw.hue, 0, 2),
88 ...(special ? { special } : {}),
89 }
90}
91
92export function sanitizeLedger(raw: unknown): Ledger | undefined {
93 if (!isRecord(raw)) return undefined
94 return {
95 tokens: num(raw.tokens, 0, BIG),
96 bricks: num(raw.bricks, 0, BIG),
97 citizens: num(raw.citizens, 0, BIG),
98 power: num(raw.power, 0, BIG),
99 parks: num(raw.parks, 0, BIG),
100 edits: num(raw.edits, 0, BIG),
101 prompts: num(raw.prompts, 0, BIG),
102 commands: num(raw.commands, 0, BIG),
103 fireworksUntil: num(raw.fireworksUntil, 0, BIG),
104 rainbowUntil: num(raw.rainbowUntil, 0, BIG),
105 showerUntil: num(raw.showerUntil, 0, BIG),
106 purchases: itemList(raw.purchases, ITEMS),
107 }
108}
109
110/** A city read from disk or the store, every field checked and every number clamped. */
111export function sanitizeCity(raw: unknown): City | undefined {
112 if (!isRecord(raw) || !Array.isArray(raw.buildings)) return undefined
113 const buildings = raw.buildings
114 .slice(0, MAX_BUILDINGS)
115 .map(building)
116 .filter((b): b is Building => b !== undefined)
117 const sessions: [string, { ledger: Ledger; at: number }][] = []
118 if (isRecord(raw.applied))
119 for (const [session, entry] of Object.entries(raw.applied).slice(0, MAX_SESSIONS * 50)) {
120 if (!/^[A-Za-z0-9-]{1,64}$/.test(session) || !isRecord(entry)) continue
121 const l = sanitizeLedger(entry.ledger)
122 if (l) sessions.push([session, { ledger: l, at: num(entry.at, 0, BIG) }])
123 }
124 // Keep the most recently active sessions: dropping a live one would re-count its earnings.
125 const applied = Object.fromEntries(sessions.sort((a, b) => b[1].at - a[1].at).slice(0, MAX_SESSIONS))
126 const tokens = num(raw.tokens, 0, BIG)
127 const project = clean(raw.project, 48)
128 return {
129 founded: num(raw.founded, 0, BIG),
130 bricks: num(raw.bricks, 0, BIG),
131 citizens: num(raw.citizens, 0, 1e9),
132 power: num(raw.power, 0, 1e9),
133 parks: whole(raw.parks, 0, 1e4),
134 edits: num(raw.edits, 0, BIG),
135 prompts: num(raw.prompts, 0, BIG),
136 commands: num(raw.commands, 0, BIG),
137 nextId: whole(raw.nextId, 1, 1e9, buildings.reduce((m, b) => Math.max(m, b.id + 1), 1)),
138 fireworksUntil: num(raw.fireworksUntil, 0, BIG),
139 showerUntil: num(raw.showerUntil, 0, BIG),
140 rainbowUntil: num(raw.rainbowUntil, 0, BIG),
141 tunnel: num(raw.tunnel, 0, MAX_TUNNEL),
142 tunnel2: num(raw.tunnel2, 0, MAX_TUNNEL),
143 tokens,
144 spent: num(raw.spent, 0, BIG),
145 owned: itemIds(raw.owned, ITEMS),
146 rev: whole(raw.rev, 0, BIG),
147 ...(project ? { project } : {}),
148 applied,
149 buildings,
150 }
151}
152
153function neighbourAgent(raw: unknown): NeighbourAgent | undefined {
154 if (!isRecord(raw)) return undefined
155 const status = STATUSES.includes(raw.status as string) ? (raw.status as string) : undefined
156 const type = clean(raw.type, 32)
157 return status && type ? { type, status } : undefined
158}
159
160/** Another session's presence file: only the fields the game draws. */
161export function sanitizeNeighbour(raw: unknown): Neighbour | undefined {
162 if (!isRecord(raw)) return undefined
163 const id = clean(raw.id, 64)
164 const slug = typeof raw.slug === 'string' && /^[A-Za-z0-9-]{1,140}$/.test(raw.slug) ? raw.slug : ''
165 if (!id || !slug) return undefined
166 return {
167 id,
168 slug,
169 project: clean(raw.project, 48) || 'unknown',
170 isBusy: raw.isBusy === true,
171 tier: clean(raw.tier, 16),
172 skyline: Array.isArray(raw.skyline) ? raw.skyline.slice(0, 60).map(h => whole(h, 0, MAX_FLOORS)) : [],
173 agents: Array.isArray(raw.agents)
174 ? raw.agents
175 .slice(0, 12)
176 .map(neighbourAgent)
177 .filter((a): a is NeighbourAgent => a !== undefined)
178 : [],
179 }
180}
181
182/** A short, stable hash of a string as hex (FNV-1a, 32 bits). */
183export function fnv(text: string) {
184 let h = 0x811c9dc5
185 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
186 return (h >>> 0).toString(16).padStart(8, '0')
187}
188hooks/shop.ts 108 lines1import type { Building, City, ItemId } from '../types'
2
3export type Item = {
4 id: ItemId
5 name: string
6 price: number
7 blurb: string
8 kind: 'street' | 'sky' | 'landmark' | 'upgrade'
9 width?: number
10 /** Presses its buy button while the pane has the keyboard; fixed per item, so a purchase never moves another's. */
11 hotkey: string
12}
13
14/** Keys the pane's own buttons hold: shop, view and zoom. */
15export const RESERVED_KEYS = ['s', 'v', 'z']
16
17export const CATALOG: Item[] = [
18 { id: 'flowers', name: 'Flower beds', price: 25_000, hotkey: '6', kind: 'street', blurb: 'Little beds of flowers along the pavement' },
19 { id: 'birds', name: 'Songbirds', price: 40_000, hotkey: '7', kind: 'sky', blurb: 'Flocks of birds flap across the daytime sky' },
20 { id: 'benches', name: 'Benches', price: 60_000, hotkey: '8', kind: 'street', blurb: 'Places to sit along the pavement' },
21 { id: 'foodtruck', name: 'Food truck', price: 90_000, hotkey: '9', kind: 'street', blurb: 'A food truck parks up, steam rising from its grill' },
22 { id: 'bunting', name: 'Bunting', price: 120_000, hotkey: '0', kind: 'street', blurb: 'Strings of bright flags sway above the street' },
23 { id: 'trees', name: 'Street trees', price: 150_000, hotkey: 'a', kind: 'street', blurb: 'Trees line the pavement and turn with the seasons' },
24 { id: 'kites', name: 'Kites', price: 200_000, hotkey: 'w', kind: 'sky', blurb: 'Kites dance over the rooftops, tails streaming' },
25 { id: 'brickworks', name: 'Brickworks', price: 300_000, hotkey: 'b', kind: 'upgrade', blurb: 'Edits and new files earn 50% more bricks' },
26 { id: 'lamps', name: 'Street lamps', price: 250_000, hotkey: 'c', kind: 'street', blurb: 'Lamps glow along the pavement at night' },
27 { id: 'balloon', name: 'Hot-air balloon', price: 400_000, hotkey: 'd', kind: 'sky', blurb: 'A striped balloon drifts over the rooftops' },
28 { id: 'plaza', name: 'Fountain plaza', price: 600_000, hotkey: 'e', kind: 'landmark', width: 9, blurb: 'A paved square with a fountain and benches' },
29 { id: 'lighthouse', name: 'Lighthouse', price: 700_000, hotkey: 'x', kind: 'landmark', width: 5, blurb: 'A striped lighthouse whose beam sweeps the night' },
30 { id: 'cityhall', name: 'City hall', price: 800_000, hotkey: 'f', kind: 'upgrade', blurb: 'Doubles the bricks the city earns while idle' },
31 { id: 'express', name: 'Express subway', price: 1_000_000, hotkey: 'g', kind: 'upgrade', blurb: 'Tunnels dig twice as fast, trains run faster' },
32 { id: 'ferris', name: 'Ferris wheel', price: 1_200_000, hotkey: 'h', kind: 'landmark', width: 13, blurb: 'A turning wheel that lights up at night' },
33 { id: 'turbine', name: 'Wind turbine', price: 1_500_000, hotkey: 'y', kind: 'landmark', width: 9, blurb: 'Tall white blades turning in the breeze' },
34 { id: 'airport', name: 'Airport', price: 2_000_000, hotkey: 'i', kind: 'sky', blurb: 'Planes cross the sky, leaving contrails' },
35 { id: 'stadium', name: 'Stadium', price: 3_000_000, hotkey: 'j', kind: 'landmark', width: 18, blurb: 'A roaring crowd under floodlights' },
36 { id: 'needle', name: 'Observation tower', price: 4_000_000, hotkey: 'k', kind: 'landmark', width: 5, blurb: 'A needle with a glowing sky deck' },
37 { id: 'castle', name: 'Castle', price: 5_000_000, hotkey: '1', kind: 'landmark', width: 11, blurb: 'Battlements, towers and a flag on the keep' },
38 { id: 'rocket', name: 'Rocket pad', price: 8_000_000, hotkey: 'l', kind: 'landmark', width: 7, blurb: 'Launches a rocket every few minutes' },
39 { id: 'pyramid', name: 'Glass pyramid', price: 10_000_000, hotkey: '2', kind: 'landmark', width: 13, blurb: 'A glass pyramid that shines a beam skyward at night' },
40 { id: 'monorail', name: 'Monorail', price: 15_000_000, hotkey: 'm', kind: 'sky', blurb: 'An elevated train glides through the skyline' },
41 { id: 'airship', name: 'Airship', price: 25_000_000, hotkey: 'n', kind: 'sky', blurb: 'A silver blimp with a glowing banner at night' },
42 { id: 'biodome', name: 'Glass biodome', price: 40_000_000, hotkey: 'o', kind: 'landmark', width: 15, blurb: 'A glass greenhouse dome full of plants' },
43 { id: 'meteors', name: 'Meteor shower', price: 60_000_000, hotkey: '3', kind: 'sky', blurb: 'Shooting stars streak across the night sky' },
44 { id: 'supertall', name: 'Supertall', price: 75_000_000, hotkey: 'p', kind: 'landmark', width: 9, blurb: 'A tiered skyscraper with a colour-changing crown' },
45 { id: 'ufo', name: 'Flying saucer', price: 100_000_000, hotkey: '4', kind: 'sky', blurb: 'Visitors hover over the city now and then, beam glowing' },
46 { id: 'station', name: 'Space station', price: 150_000_000, hotkey: 'q', kind: 'sky', blurb: 'Crosses the sky with its solar panels gleaming' },
47 { id: 'moonbase', name: 'Moon base', price: 300_000_000, hotkey: 'r', kind: 'sky', blurb: 'Lights on the moon, and a shuttle to reach it' },
48 { id: 'elevator', name: 'Space elevator', price: 500_000_000, hotkey: 't', kind: 'landmark', width: 5, blurb: 'A tether into the sky with a climber riding it' },
49 { id: 'ring', name: 'Orbital ring', price: 1_000_000_000, hotkey: 'u', kind: 'sky', blurb: 'A luminous ring arcs across the whole sky' },
50 { id: 'dyson', name: 'Dyson swarm', price: 2_000_000_000, hotkey: '5', kind: 'sky', blurb: 'Mirrors circle the sun and beam down power to keep the factories running' },
51]
52
53/** The most buildings a city keeps; the sanitizer drops any past it, so landmarks stop here. */
54export const MAX_BUILDINGS = 400
55
56export const itemOf = (id: ItemId) => CATALOG.find(i => i.id === id)
57export const owns = (c: City, id: ItemId) => (c.owned ?? []).includes(id)
58export const balanceOf = (c: City) => (c.tokens ?? 0) - (c.spent ?? 0)
59
60/** Tokens a turn counts for: cache reads are billed at a tenth, so they count a tenth. */
61export function weighTokens(u: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }) {
62 const n = (v: number | undefined) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
63 return n(u.input_tokens) + n(u.output_tokens) + n(u.cache_creation_input_tokens) + Math.round(n(u.cache_read_input_tokens) / 10)
64}
65
66const trim = (n: number, digits: number) => `${Number(n.toFixed(digits))}`
67
68export function formatTokens(n: number) {
69 if (n >= 1_000_000_000) return `${trim(n / 1_000_000_000, n >= 10_000_000_000 ? 0 : 1)}B`
70 if (n >= 1_000_000) return `${trim(n / 1_000_000, n >= 10_000_000 ? 0 : 1)}M`
71 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
72 return `${Math.round(n)}`
73}
74
75/** Lays buildings out left to right again, one pixel apart, keeping their order. */
76function relayout(buildings: Building[]) {
77 let x = 0
78 return buildings.map(b => {
79 const placed = { ...b, x }
80 x += b.w + 1
81 return placed
82 })
83}
84
85export type Purchase = { city: City; error?: undefined } | { city?: undefined; error: string }
86
87export function buy(c: City, id: ItemId): Purchase {
88 const item = itemOf(id)
89 if (!item) return { error: `There is no ${id} for sale.` }
90 if (owns(c, id)) return { error: `The city already has ${item.name.toLowerCase()}.` }
91 const short = item.price - balanceOf(c)
92 if (short > 0) return { error: `${item.name} needs ${formatTokens(short)} more tokens.` }
93 if (item.kind === 'landmark' && c.buildings.length >= MAX_BUILDINGS) return { error: 'The city has no room left for another landmark.' }
94
95 const next: City = { ...c, spent: (c.spent ?? 0) + item.price, owned: [...(c.owned ?? []), id] }
96 if (item.kind !== 'landmark' || !item.width) return { city: next }
97
98 const special: Building = { id: c.nextId, x: 0, w: item.width, kind: 'special', special: id, floors: 0, target: 1, hue: 0 }
99 const at = Math.floor(c.buildings.length / 2)
100 return {
101 city: {
102 ...next,
103 nextId: c.nextId + 1,
104 buildings: relayout([...c.buildings.slice(0, at), special, ...c.buildings.slice(at)]),
105 },
106 }
107}
108hooks/sync.ts 63 lines1import type { City, Ledger } from '../types'
2import { buy } from './shop'
3
4export const LEASE_MS = 8_000
5export const LEDGER_FRESH_MS = 6 * 3_600_000
6const APPLIED_KEEP_MS = 7 * 86_400_000
7
8const COUNTS = ['tokens', 'bricks', 'citizens', 'power', 'parks', 'edits', 'prompts', 'commands'] as const
9const MOMENTS = ['fireworksUntil', 'rainbowUntil', 'showerUntil'] as const
10
11export const emptyLedger = (): Ledger => ({
12 tokens: 0,
13 bricks: 0,
14 citizens: 0,
15 power: 0,
16 parks: 0,
17 edits: 0,
18 prompts: 0,
19 commands: 0,
20 fireworksUntil: 0,
21 rainbowUntil: 0,
22 showerUntil: 0,
23 purchases: [],
24})
25
26/** Folds what a session has earned since the city last heard from it into the city. */
27export function applyLedger(c: City, session: string, ledger: Ledger, now: number): City {
28 const seen = c.applied?.[session]?.ledger ?? emptyLedger()
29 let next: City = { ...c }
30 for (const key of COUNTS) next[key] = (next[key] ?? 0) + Math.max(0, ledger[key] - seen[key])
31 for (const key of MOMENTS) next[key] = Math.max(next[key] ?? 0, ledger[key])
32 for (const id of ledger.purchases.slice(seen.purchases.length)) next = buy(next, id).city ?? next
33 // What has been counted only ever grows, so an older copy of a ledger read late can't be paid out twice.
34 const counted: Ledger = { ...ledger, purchases: ledger.purchases.length >= seen.purchases.length ? ledger.purchases : seen.purchases }
35 for (const key of [...COUNTS, ...MOMENTS]) counted[key] = Math.max(seen[key], ledger[key])
36 return { ...next, applied: { ...c.applied, [session]: { ledger: counted, at: now } } }
37}
38
39export function pruneApplied(c: City, now: number): City {
40 // Entries dated ahead of now come from a skewed clock or a hand edit; they'd hold a slot forever.
41 const kept = Object.entries(c.applied ?? {}).filter(([, a]) => now - a.at < APPLIED_KEEP_MS && a.at <= now + 60_000)
42 return { ...c, applied: Object.fromEntries(kept) }
43}
44
45export type Lease = { id: string; until: number }
46
47/** One election round: whether to write a fresh lease, and whether this session runs the city now. */
48export function contest(lease: Lease | undefined, self: string, now: number) {
49 // A lease claiming more than one renewal ahead is forged or from a skewed clock: treat it as lapsed.
50 const isHeld = lease !== undefined && lease.until >= now && lease.until <= now + LEASE_MS + 2_000
51 const isOpen = !isHeld || lease.id === self
52 return {
53 write: isOpen ? { id: self, until: now + LEASE_MS } : undefined,
54 isMayor: isHeld && lease.id === self,
55 }
56}
57
58export const slugOf = (cwd: string) =>
59 cwd
60 .replace(/[^A-Za-z0-9]+/g, '-')
61 .replace(/^-+|-+$/g, '')
62 .slice(-120) || 'root'
63hooks/weather.ts 278 lines1import type { City } from '../types'
2import { hash, mix, PAVEMENT, STREET } from './pixels'
3
4export type WeatherKind = 'clear' | 'cloudy' | 'rain' | 'storm' | 'snow' | 'fog'
5type Season = 'winter' | 'spring' | 'summer' | 'autumn'
6
7export type Weather = {
8 kind: WeatherKind
9 season: Season
10 slot: number
11 intensity: number
12 wind: number
13 overcast: number
14 snow: number
15 wet: number
16 rainbow: number
17 aurora: number
18}
19
20export type Sky = {
21 W: number
22 ground: number
23 now: number
24 light: number
25 isNight: boolean
26 set: (x: number, y: number, col: number) => void
27 get: (x: number, y: number) => number
28}
29
30const SLOT_MS = 150_000
31export const WEATHER_CHANCE = 0.25
32const RAMP = 0.2
33const CHANCES: Record<Season, Record<Exclude<WeatherKind, 'clear'>, number>> = {
34 winter: { cloudy: 25, rain: 12, storm: 3, snow: 45, fog: 15 },
35 spring: { cloudy: 30, rain: 40, storm: 10, snow: 3, fog: 17 },
36 summer: { cloudy: 35, rain: 30, storm: 25, snow: 0, fog: 10 },
37 autumn: { cloudy: 30, rain: 38, storm: 10, snow: 4, fog: 18 },
38}
39const OVERCAST: Record<WeatherKind, number> = { clear: 0, cloudy: 0.35, rain: 0.55, storm: 0.8, snow: 0.45, fog: 0.5 }
40const isWet = (k: WeatherKind) => k === 'rain' || k === 'storm'
41
42function seasonOf(now: number): Season {
43 const month = new Date(now).getMonth()
44 if (month === 11 || month <= 1) return 'winter'
45 if (month <= 4) return 'spring'
46 if (month <= 7) return 'summer'
47 return 'autumn'
48}
49
50const rolls = (slot: number) => hash(slot, 40) < WEATHER_CHANCE
51
52/** Most spells are clear, and a spell of weather is never followed by another. */
53function kindOf(slot: number, season: Season): WeatherKind {
54 if (!rolls(slot) || rolls(slot - 1)) return 'clear'
55 const entries = Object.entries(CHANCES[season]) as [WeatherKind, number][]
56 let left = hash(slot, 41) * entries.reduce((n, [, w]) => n + w, 0)
57 for (const [kind, w] of entries) if ((left -= w) < 0) return kind
58 return 'clear'
59}
60
61export function weatherAt(now: number, c: City): Weather {
62 const slot = Math.floor(now / SLOT_MS)
63 const t = (now % SLOT_MS) / SLOT_MS
64 const season = seasonOf(now)
65 const prev = kindOf(slot - 1, season)
66 let kind = kindOf(slot, season)
67 let intensity = kind === 'clear' ? 0 : Math.min(1, t / RAMP, (1 - t) / RAMP) * (0.4 + 0.4 * hash(slot, 43))
68
69 const shower = (c.showerUntil ?? 0) - now
70 if (shower > 0 && kind !== 'storm' && kind !== 'snow' && (c.rainbowUntil ?? 0) <= now) {
71 kind = 'rain'
72 intensity = Math.max(intensity, 0.35 * Math.min(1, shower / 4000))
73 }
74
75 const rainbowFor = (c.rainbowUntil ?? 0) - now
76 const afterRain = isWet(prev) && (kind === 'clear' || kind === 'cloudy') ? Math.min(1, t / 0.08) * Math.max(0, Math.min(1, (0.45 - t) / 0.25)) : 0
77
78 return {
79 kind,
80 season,
81 slot,
82 intensity,
83 wind: (hash(slot, 44) * 2 - 1) * (kind === 'storm' ? 1 : 0.5),
84 overcast: OVERCAST[kind] * intensity,
85 snow: kind === 'snow' ? Math.min(1, t * 2.5) : prev === 'snow' ? Math.max(0, 1 - t * 3) : 0,
86 wet: isWet(kind) ? Math.min(1, intensity * 1.5) : isWet(prev) ? Math.max(0, 1 - t * 2.5) : 0,
87 rainbow: rainbowFor > 0 ? Math.min(1, rainbowFor / 3000) : afterRain,
88 aurora: kind === 'clear' && hash(slot, 45) < (season === 'winter' ? 0.6 : 0.2) ? 1 : 0,
89 }
90}
91
92export function weatherLabel(w: Weather, isNight: boolean) {
93 if (w.kind === 'clear') {
94 if (w.rainbow > 0.3) return 'rainbow'
95 if (w.aurora && isNight) return 'aurora'
96 return isNight ? 'clear night' : 'sunny'
97 }
98 const words: Record<WeatherKind, string> = {
99 clear: 'clear',
100 cloudy: 'cloudy',
101 rain: w.intensity > 0.75 ? 'pouring' : 'raining',
102 storm: 'thunderstorm',
103 snow: w.intensity > 0.75 ? 'blizzard' : 'snowing',
104 fog: 'foggy',
105 }
106 return words[w.kind]
107}
108
109/** Tints a sky colour toward overcast grey. */
110export const overcastTint = (col: number, w: Weather, light: number) => mix(col, mix(0x1a1e26, 0x7a828e, light), w.overcast)
111
112export function drawAurora(sky: Sky, w: Weather) {
113 if (!w.aurora || sky.light > 0.25) return
114 const { W, now, set, get } = sky
115 const strength = Math.min(1, ((0.25 - sky.light) / 0.25) * 1.6)
116 for (let x = 0; x < W; x++) {
117 const centre = 3 + Math.sin(x / 7 + now / 2600) * 2 + Math.sin(x / 3.1 + now / 1700)
118 const curtain = 0.5 + 0.5 * Math.sin(x / 2.3 + now / 900)
119 const col = mix(0x40ff9a, 0xa060ff, 0.5 + 0.5 * Math.sin(x / 17 + now / 5000))
120 for (let dy = 0; dy < 5; dy++) {
121 const y = Math.round(centre) + dy
122 if (y < 0 || y >= sky.ground) continue
123 set(x, y, mix(get(x, y), col, strength * curtain * 0.8 * (1 - dy / 5)))
124 }
125 }
126}
127
128export function drawRainbow(sky: Sky, w: Weather) {
129 if (w.rainbow <= 0 || sky.light < 0.5) return
130 const { W, ground, set, get } = sky
131 const bands = [0xff4040, 0xff9a40, 0xffe040, 0x50d050, 0x4090ff, 0x8050d0]
132 const cx = Math.round(W * 0.68)
133 const cy = ground + 4
134 const r0 = Math.min(W * 0.45, ground * 1.1)
135 for (let x = 0; x < W; x++)
136 bands.forEach((col, i) => {
137 const r = r0 - i
138 const dx = x - cx
139 if (Math.abs(dx) > r) return
140 const y = Math.round(cy - Math.sqrt(r * r - dx * dx) * 0.8)
141 if (y >= 0 && y < ground) set(x, y, mix(get(x, y), col, 0.4 * w.rainbow))
142 })
143}
144
145export function drawClouds(sky: Sky, w: Weather) {
146 const { W, ground, now, light, set, get } = sky
147 const counts: Record<WeatherKind, number> = { clear: 2, cloudy: 6, rain: 7, storm: 9, snow: 6, fog: 3 }
148 const base: Record<WeatherKind, number> = { clear: 0xf4f6f8, cloudy: 0xe2e6ea, rain: 0x9aa2ae, storm: 0x4a505c, snow: 0xd8dde4, fog: 0xc8ccd2 }
149 const tone = mix(mix(0x1c2030, base[w.kind], light), 0x10131c, w.kind === 'storm' ? 0.2 : 0)
150 const n = Math.round(counts[w.kind] * (w.kind === 'clear' ? 1 : 0.4 + 0.6 * w.intensity))
151
152 if (w.overcast > 0.3)
153 for (let y = 0; y < 3; y++)
154 for (let x = 0; x < W; x++) set(x, y, mix(get(x, y), tone, (w.overcast - 0.3) * (1.4 - y * 0.35)))
155
156 for (let i = 0; i < n; i++) {
157 const cw = 7 + Math.round(hash(i, w.slot, 1) * 10)
158 const loop = W + cw * 2
159 const x0 = Math.round(((hash(i, 3) * loop + (now / 1000) * (1.2 + Math.abs(w.wind) * 4)) % loop) - cw)
160 const y0 = 1 + Math.round(hash(i, 2) * ground * 0.3)
161 const alpha = w.kind === 'clear' ? 0.6 : 0.88
162 for (let r = 0; r < 3; r++) {
163 const inset = r === 0 ? 2 + Math.round(hash(i, 4) * 2) : r === 1 ? 0 : 1
164 for (let x = x0 + inset; x < x0 + cw - inset; x++) {
165 if (r === 0 && hash(i, x) < 0.25) continue
166 const y = y0 + r
167 if (x < 0 || x >= W || y >= ground) continue
168 set(x, y, mix(get(x, y), r === 2 ? mix(tone, 0x000000, 0.12) : tone, alpha))
169 }
170 }
171 }
172}
173
174export function strikeAt(now: number, w: Weather) {
175 if (w.kind !== 'storm' || w.intensity < 0.4) return undefined
176 const id = Math.floor(now / 180)
177 return hash(id, w.slot, 7) > 0.994 ? id : undefined
178}
179
180export function drawBolt(sky: Sky, w: Weather) {
181 const id = strikeAt(sky.now, w)
182 if (id === undefined) return
183 let x = Math.round(hash(id, 1) * sky.W)
184 for (let y = 0; y < sky.ground; y++) {
185 sky.set(x, y, 0xfafaff)
186 if (hash(id, y) > 0.55) x += hash(id, y, 2) > 0.5 ? 1 : -1
187 if (hash(id, y, 3) > 0.9) sky.set(x + 1, y + 1, 0xc8d0ff)
188 }
189}
190
191export function drawSnowCover(sky: Sky, w: Weather, roofTop: Int16Array, shade: (col: number) => number) {
192 if (w.snow <= 0) return
193 const cap = shade(0xf4f8ff)
194 for (let x = 0; x < sky.W; x++) {
195 const y = roofTop[x] ?? sky.ground
196 if (y <= 0 || y >= sky.ground) continue
197 if (w.snow > hash(x, 77) * 0.9) sky.set(x, y - 1, cap)
198 if (w.snow > 0.7 && hash(x, 78) > 0.6) sky.set(x, y, cap)
199 }
200}
201
202export function drawStreetWeather(sky: Sky, w: Weather) {
203 const { W, ground, set, get, now } = sky
204 for (let x = 0; x < W; x++) {
205 if (w.snow > 0) {
206 for (let y = ground; y < ground + PAVEMENT; y++)
207 if (w.snow > hash(x, y, 79) * 0.8) set(x, y, mix(get(x, y), 0xeef2f8, 0.8))
208 if (w.snow > hash(x, 80)) set(x, ground + STREET - 1, mix(get(x, ground + STREET - 1), 0xdfe4ec, 0.35))
209 }
210 if (w.wet > 0) {
211 for (let y = ground; y < ground + PAVEMENT; y++) set(x, y, mix(get(x, y), 0x30343c, w.wet * 0.35))
212 for (const y of [ground + PAVEMENT, ground + PAVEMENT + 1]) {
213 const glint = hash(x, y, Math.floor(now / 1200)) > 0.86
214 if (glint) set(x, y, mix(get(x, y), sky.isNight ? 0x8a8060 : 0xa8c0d8, 0.45 * w.wet))
215 }
216 }
217 }
218}
219
220export function drawFog(sky: Sky, w: Weather) {
221 if (w.kind !== 'fog' || w.intensity <= 0) return
222 const { W, ground, now, light, set, get } = sky
223 const fog = mix(0x2a2e38, 0xc8ccd2, light)
224 for (let y = 0; y < ground + STREET; y++)
225 for (let x = 0; x < W; x++) {
226 const drift = 0.15 * Math.sin(x / 11 + now / 3000 + y / 3)
227 set(x, y, mix(get(x, y), fog, w.intensity * (0.45 * (0.3 + 0.7 * (y / ground)) + drift * 0.6)))
228 }
229}
230
231export function drawPrecipitation(sky: Sky, w: Weather) {
232 const { W, ground, now, set, get } = sky
233 const secs = now / 1000
234
235 if (isWet(w.kind) && w.intensity > 0) {
236 const heavy = w.kind === 'storm'
237 const n = Math.round((w.intensity * W * ground) / (heavy ? 22 : 40))
238 const lean = w.wind * (heavy ? 0.8 : 0.4)
239 const fall = ground + 6
240 for (let i = 0; i < n; i++) {
241 const speed = (heavy ? 48 : 34) + hash(i, 1) * 14
242 const y = Math.round(((hash(i, 2) * fall + secs * speed) % fall) - 3)
243 const x = Math.round(hash(i, 3) * (W + 20) - 10 + y * lean)
244 const drop = (px: number, py: number) => {
245 if (px >= 0 && px < W && py >= 0 && py < ground) set(px, py, mix(get(px, py), 0xb8c8e0, 0.35))
246 }
247 drop(x, y)
248 drop(x - Math.round(lean), y - 1)
249 if (y >= ground - 1 && x >= 1 && x < W - 1) {
250 set(x - 1, ground - 1, mix(get(x - 1, ground - 1), 0xd8e4f4, 0.3))
251 set(x + 1, ground - 1, mix(get(x + 1, ground - 1), 0xd8e4f4, 0.3))
252 }
253 }
254 }
255
256 if (w.kind === 'snow' && w.intensity > 0) {
257 const n = Math.round((w.intensity * W * ground) / 30)
258 for (let i = 0; i < n; i++) {
259 const speed = 3 + hash(i, 1) * 4 + w.intensity * 3
260 const y = Math.round((hash(i, 2) * ground + secs * speed) % ground)
261 const sway = Math.sin(secs * 1.3 + i) * 1.5 + secs * w.wind * 4
262 const x = Math.round((((hash(i, 3) * W + sway) % W) + W) % W)
263 set(x, y, mix(get(x, y), 0xffffff, 0.7))
264 }
265 }
266
267 const drifting = (w.season === 'autumn' || w.season === 'spring') && (w.kind === 'clear' || w.kind === 'cloudy')
268 if (drifting) {
269 const colours = w.season === 'autumn' ? [0xe08030, 0xc0502a, 0xd8a030] : [0xf8c0d8, 0xffe0ee]
270 const n = Math.round(W / 35)
271 for (let i = 0; i < n; i++) {
272 const y = Math.round((hash(i, 5) * ground + secs * (2 + hash(i, 6) * 2)) % ground)
273 const x = Math.round((((hash(i, 7) * W + secs * (3 + w.wind * 6) + Math.sin(secs * 2 + i) * 2) % W) + W) % W)
274 set(x, y, colours[i % colours.length] ?? 0xe08030)
275 }
276 }
277}
278hooks/buildings.ts 270 lines1import type { Building } from '../types'
2import { hash, mix } from './pixels'
3import { drawSpecial } from './specials'
4
5export type Canvas = {
6 W: number
7 set: (x: number, y: number, col: number) => void
8 fx: (x: number, y: number, col: number) => void
9 get: (x: number, y: number) => number
10 shade: (col: number) => number
11 isNight: boolean
12 now: number
13 ground: number
14}
15
16const pick = (list: number[] | undefined, roll: number) => list?.[Math.floor(roll * list.length)] ?? list?.[0] ?? 0x888888
17
18const WALLS: Record<Exclude<Building['kind'], 'park' | 'special'>, number[]> = {
19 house: [0xd9b38c, 0xc98f6a, 0xe0c9a6, 0xf0e6d2, 0xa9c4d9, 0xc9d9a9, 0xe6b8b8],
20 shop: [0x6d8fb3, 0xb3706d, 0x7fa86a, 0xd9a441, 0x8a6db3, 0x4fa3a3],
21 tower: [0x55657a, 0x46536a, 0x67788f, 0x3d5a6b, 0x5a4d6b, 0x2f3a4a, 0x7a8a9a],
22 apartment: [0xa0522d, 0x8b4513, 0xb5651d, 0x9c6b5a, 0xc08060],
23 civic: [0xeae2d0, 0xd8d0c0, 0xf2f2f2, 0xd9c7a0],
24 landmark: [0xc8b07a, 0x9a9a9a, 0xb08060],
25 factory: [0x7a6a5a, 0x6b6460, 0x846f5c, 0x5a6b5a],
26}
27const ROOFS = [0xa3442f, 0x6b4a3a, 0x4a5568, 0x3f6b4a, 0x8b3a62]
28const AWNINGS = [0xd94040, 0x3f9a5a, 0x3f6fd9, 0xe0a030, 0x9a40c0]
29const NEON = [0xff4fd8, 0x4ff0ff, 0xfff04f, 0x7fff4f]
30const GLASS = [0x9fd3f5, 0x8fe0d0, 0xb0c8ff, 0xf5d79f]
31const DOMES = [0x5fa89a, 0xd4af37, 0x8a9aa8]
32const FOLIAGE = [0x2f7d3a, 0x3f9a4a, 0xf0a0c0, 0xe08030, 0x1f5f3a]
33
34type Pattern = 'grid' | 'glass' | 'curtain' | 'vertical' | 'sparse'
35
36function patternOf(b: Building, s: (k: number) => number): Pattern {
37 if (b.kind === 'tower') return (['glass', 'curtain', 'vertical', 'grid'] as const)[Math.floor(s(1) * 4)] ?? 'glass'
38 if (b.kind === 'civic' || b.kind === 'factory' || b.kind === 'landmark') return 'sparse'
39 return 'grid'
40}
41
42function isWindow(p: Pattern, col: number, isWindowRow: boolean) {
43 if (p === 'curtain' || p === 'vertical') return p === 'curtain' || col % 2 === 1
44 if (!isWindowRow) return false
45 if (p === 'glass') return true
46 if (p === 'sparse') return col % 3 === 1
47 return col % 2 === 1
48}
49
50export function drawBuilding(cv: Canvas, b: Building, x0: number) {
51 const { set, shade, isNight, now } = cv
52 const s = (k: number) => hash(b.id, 100 + k)
53 const yb = cv.ground - 1
54 const mid = x0 + Math.floor(b.w / 2)
55
56 if (b.kind === 'park') return drawPark(cv, b, x0, s)
57 if (b.kind === 'special') return drawSpecial(cv, b, x0)
58
59 const wall = shade(pick(WALLS[b.kind], s(0)))
60 const tint = pick(GLASS, s(7))
61 const pattern = patternOf(b, s)
62 const setback = b.kind === 'tower' && b.floors >= 6 && s(2) < 0.4
63 const windowAt = (x: number, y: number) => {
64 if (!isNight) return mix(tint, 0xffffff, hash(b.id, x, y) * 0.3)
65 return hash(b.id, x, y, Math.floor(now / 9000 + hash(b.id, x))) > 0.45 ? 0xffd27a : 0x262b3a
66 }
67
68 for (let f = 0; f < b.floors; f++) {
69 const inset = setback && f >= b.floors - 2 ? 1 : 0
70 for (let r = 0; r < 2; r++) {
71 const y = yb - f * 2 - r
72 const isWindowRow = r === 1
73 for (let x = x0 + inset; x < x0 + b.w - inset; x++) {
74 const col = x - x0
75 const inner = x > x0 + inset && x < x0 + b.w - 1 - inset
76 let px = inner && isWindow(pattern, col, isWindowRow) ? windowAt(col, y) : wall
77 if (pattern === 'curtain' && !isWindowRow && inner) px = mix(px, wall, 0.5)
78 if (b.kind === 'apartment' && !isWindowRow && f > 0 && col % 2 === 1) px = shade(0x3a3a3a)
79 set(x, y, px)
80 }
81 }
82 }
83
84 const roofY = yb - b.floors * 2
85 if (b.floors > 0) {
86 if (b.kind === 'shop') {
87 const awning = pick(AWNINGS, s(3))
88 const isStriped = s(4) < 0.6
89 for (let x = x0; x < x0 + b.w; x++) set(x, yb - 1, isStriped && (x - x0) % 2 === 0 ? 0xf2f2f2 : awning)
90 }
91 if (b.kind === 'civic') for (let x = x0; x < x0 + b.w; x++) set(x, yb, (x - x0) % 2 ? shade(0xffffff) : shade(0x8a8478))
92 if (b.kind !== 'tower' && b.kind !== 'civic') set(mid, yb, shade(0x3a2a1e))
93 drawRoof(cv, b, x0, roofY, s, setback ? 1 : 0)
94 }
95
96 if (b.floors < b.target) drawCrane(cv, b, x0, roofY)
97}
98
99function drawRoof(cv: Canvas, b: Building, x0: number, roofY: number, s: (k: number) => number, inset: number) {
100 const { set, shade, isNight, now } = cv
101 const mid = x0 + Math.floor(b.w / 2)
102 const blink = Math.floor(now / 800) % 2 === 0
103 const flat = (col: number) => {
104 for (let x = x0 + inset; x < x0 + b.w - inset; x++) set(x, roofY, shade(col))
105 }
106
107 if (b.kind === 'house') {
108 const roof = shade(pick(ROOFS, s(3)))
109 const style = Math.floor(s(4) * 4)
110 if (style === 2) {
111 flat(0x4a4a52)
112 } else if (style === 3) {
113 for (let x = x0; x < x0 + b.w; x++) set(x, roofY, roof)
114 for (let x = x0 + 1; x < x0 + b.w - 1; x++) set(x, roofY - 1, roof)
115 } else {
116 const rows = style === 1 ? Math.ceil(b.w / 2) : 3
117 for (let i = 0; i < rows; i++) for (let x = x0 + i; x < x0 + b.w - i; x++) set(x, roofY - i, roof)
118 }
119 if (s(5) < 0.5) {
120 const cx = x0 + b.w - 2
121 set(cx, roofY - 1, shade(0x7a3a2a))
122 set(cx, roofY - 2, shade(0x7a3a2a))
123 }
124 } else if (b.kind === 'shop') {
125 flat(0x4a4a52)
126 const neon = pick(NEON, s(5))
127 for (let x = mid - 1; x <= mid + (b.w > 5 ? 1 : 0); x++) set(x, roofY - 1, isNight ? (blink || s(6) < 0.5 ? neon : 0x30303a) : shade(0xe8e8e8))
128 } else if (b.kind === 'apartment') {
129 flat(0x5a4a40)
130 const extra = Math.floor(s(5) * 3)
131 if (extra === 0) {
132 const wx = x0 + 1
133 set(wx, roofY - 1, shade(0x5a4030))
134 set(wx + 2, roofY - 1, shade(0x5a4030))
135 for (let x = wx; x <= wx + 2; x++) for (let y = roofY - 3; y <= roofY - 2; y++) set(x, y, shade(0x8a5a3a))
136 } else if (extra === 1) {
137 for (let x = x0 + 1; x < x0 + b.w - 1; x++) set(x, roofY - 1, shade(hash(b.id, x) > 0.4 ? 0x4f9a4a : 0x7fc070))
138 }
139 } else if (b.kind === 'tower') {
140 flat(0x39465a)
141 const crown = Math.floor(s(6) * 5)
142 const light = isNight && blink ? 0xff3030 : shade(0x9aa4b2)
143 if (crown === 0) {
144 set(mid, roofY - 1, shade(0x9aa4b2))
145 set(mid, roofY - 2, light)
146 } else if (crown === 1) {
147 const half = Math.floor((b.w - 2 * inset) / 2)
148 for (let i = 1; i <= half; i++) for (let x = mid - half + i; x <= mid + half - i; x++) set(x, roofY - i, shade(0x7a8a9a))
149 set(mid, roofY - half - 1, light)
150 } else if (crown === 2) {
151 set(x0 + inset, roofY - 1, isNight && blink ? 0x40ff60 : shade(0x9aa4b2))
152 set(x0 + b.w - 1 - inset, roofY - 1, isNight && !blink ? 0x40ff60 : shade(0x9aa4b2))
153 set(mid, roofY, shade(0xe8e8e8))
154 } else if (crown === 3) {
155 for (let x = x0 + inset + 1; x < x0 + b.w - inset - 1; x++) set(x, roofY - 1, shade(0x2a3442))
156 for (let x = x0 + inset + 2; x < x0 + b.w - inset - 2; x++) set(x, roofY - 2, shade(0x2a3442))
157 } else {
158 for (const ax of [x0 + inset + 1, x0 + b.w - inset - 2]) {
159 set(ax, roofY - 1, shade(0x9aa4b2))
160 set(ax, roofY - 2, shade(0x9aa4b2))
161 set(ax, roofY - 3, light)
162 }
163 }
164 } else if (b.kind === 'civic') {
165 flat(0xd0c8b8)
166 if (s(5) < 0.55) {
167 const dome = shade(pick(DOMES, s(6)))
168 const r = Math.max(1, Math.floor(b.w / 2) - 1)
169 for (let dy = 1; dy <= r; dy++) {
170 const half = Math.round(Math.sqrt(r * r - (dy - 1) * (dy - 1)))
171 for (let x = mid - half; x <= mid + half; x++) set(x, roofY - dy, dome)
172 }
173 set(mid, roofY - r - 1, dome)
174 } else {
175 for (let i = 1; i <= Math.floor(b.w / 2); i++) for (let x = x0 + i; x < x0 + b.w - i; x++) set(x, roofY - i, shade(0xe8e0d0))
176 }
177 const pole = x0 + b.w - 1
178 for (let y = roofY - 1; y > roofY - 5; y--) set(pole, y, shade(0x9a9a9a))
179 const flag = pick([0xd94040, 0x3f6fd9, 0x3f9a5a], s(7))
180 set(pole - 1, roofY - 4, shade(flag))
181 set(pole - 2, roofY - 4 + (blink ? 0 : 1), shade(flag))
182 } else if (b.kind === 'landmark') {
183 const style = Math.floor(s(5) * 3)
184 if (style === 0) {
185 const face = roofY + 1
186 set(mid - 1, face, shade(0xf8f4e8))
187 set(mid, face, Math.floor(now / 1000) % 2 ? shade(0x202020) : shade(0xf8f4e8))
188 for (let i = 0; i < 4; i++) for (let x = x0 + Math.floor(i / 2); x < x0 + b.w - Math.floor(i / 2); x++) set(x, roofY - i, shade(0x5a6a7a))
189 set(mid, roofY - 4, shade(0xd4af37))
190 } else if (style === 1) {
191 const r = Math.max(2, Math.floor(b.w / 2))
192 for (let dy = 0; dy < r; dy++) {
193 const half = Math.round(Math.sqrt(r * r - dy * dy))
194 for (let x = mid - half; x <= mid + half; x++) set(x, roofY - dy, shade(0xe8ecf0))
195 }
196 const slit = Math.floor(now / 3000) % 3
197 for (let dy = 1; dy < r; dy++) set(mid - 1 + slit, roofY - dy, 0x101828)
198 } else {
199 const eave = shade(0xb03a2e)
200 for (let f = 0; f <= b.floors; f += 2) {
201 const y = cv.ground - 1 - f * 2
202 for (let x = x0 - 1; x <= x0 + b.w; x++) set(x, y, eave)
203 }
204 set(mid, roofY - 1, eave)
205 set(mid, roofY - 2, shade(0xd4af37))
206 }
207 } else if (b.kind === 'factory') {
208 for (let x = x0; x < x0 + b.w; x++) set(x, roofY - ((x - x0) % 3 === 0 ? 1 : 0), shade(0x5a4e44))
209 const stacks = s(5) < 0.5 ? [x0 + b.w - 2] : [x0 + 1, x0 + b.w - 2]
210 stacks.forEach((cx, j) => {
211 const tall = 4 + Math.floor(s(6 + j) * 3)
212 for (let y = roofY; y > roofY - tall; y--) set(cx, y, shade(y % 3 === 0 ? 0xe8e8e8 : 0x8a3b2b))
213 for (let k = 0; k < 4; k++) {
214 const rise = (now / 350 + k * 2.5 + j * 4) % 9
215 const sx = cx + Math.round(Math.sin(now / 600 + k) + rise / 3)
216 const sy = Math.round(roofY - tall - rise)
217 if (sy >= 0 && sx >= 0 && sx < cv.W) cv.fx(sx, sy, mix(cv.get(sx, sy), 0xc8c8c8, 0.7 - rise / 14))
218 }
219 })
220 } else {
221 flat(0x4a4a52)
222 }
223}
224
225function drawPark(cv: Canvas, b: Building, x0: number, s: (k: number) => number) {
226 const { set, shade, now } = cv
227 const yb = cv.ground - 1
228 for (let x = x0; x < x0 + b.w; x++) set(x, yb, shade(0x4f9a4a))
229 const feature = Math.floor(s(1) * 3)
230 for (let tx = x0 + 1, k = 0; tx < x0 + b.w - 1; tx += 3, k++) {
231 if (feature === 1 && k === 1) {
232 set(tx, yb - 1, shade(0x9aa4b2))
233 set(tx, yb - 2 - (Math.floor(now / 400) % 2), 0x6fc8f0)
234 continue
235 }
236 if (feature === 2 && k === 1) {
237 set(tx - 1, yb - 1, shade(0x6b4a2b))
238 set(tx, yb - 1, shade(0x6b4a2b))
239 continue
240 }
241 const leaf = shade(pick(FOLIAGE, hash(b.id, tx)))
242 const isPine = hash(b.id, tx, 3) < 0.35
243 set(tx, yb - 1, shade(0x6b4a2b))
244 if (isPine) {
245 for (let dy = 2; dy <= 3; dy++) for (let dx = -1; dx <= 1; dx++) set(tx + dx, yb - dy, shade(0x1f5f3a))
246 set(tx, yb - 4, shade(0x1f5f3a))
247 set(tx, yb - 5, shade(0x1f5f3a))
248 } else {
249 for (let dx = -1; dx <= 1; dx++) for (let dy = 2; dy <= 3; dy++) set(tx + dx, yb - dy, leaf)
250 set(tx, yb - 4, leaf)
251 }
252 }
253 if (b.floors < b.target) set(x0 + Math.floor(b.w / 2), yb - 1, 0xf2b33d)
254}
255
256function drawCrane(cv: Canvas, b: Building, x0: number, roofY: number) {
257 const { set, now } = cv
258 const yb = cv.ground - 1
259 const scaffold = 0xf2b33d
260 for (let y = roofY; y > roofY - 2; y--) for (let x = x0; x < x0 + b.w; x++) if ((x + y) % 2 === 0) set(x, y, scaffold)
261 const mast = x0 + b.w
262 const topY = Math.max(1, yb - b.target * 2 - 3)
263 for (let y = yb; y >= topY; y--) set(mast, y, scaffold)
264 for (let x = x0 - 2; x <= mast + 2; x++) set(x, topY, scaffold)
265 const hookX = x0 + Math.floor(b.w / 2)
266 const drop = topY + 1 + Math.round((Math.sin(now / 700) + 1) * 1.5)
267 for (let y = topY + 1; y < drop; y++) cv.fx(hookX, y, 0x707070)
268 cv.fx(hookX, drop, 0x9a5a2a)
269}
270hooks/pixels.ts 23 lines1/** Pixel rows of pavement under the buildings, and of pavement plus road. */
2export const PAVEMENT = 3
3export const STREET = PAVEMENT + 2
4
5export function hash(...n: number[]) {
6 let h = 2166136261
7 for (const v of n) {
8 h = Math.imul(h ^ (v | 0), 16777619)
9 h ^= h >>> 13
10 }
11 return ((h >>> 0) % 100000) / 100000
12}
13
14export const hashStr = (s: string) => hash(...Array.from(s, ch => ch.charCodeAt(0)))
15
16const rgb = (r: number, g: number, b: number) => (r << 16) | (g << 8) | b
17const ch = (c: number, s: number) => (c >> s) & 255
18export function mix(a: number, b: number, t: number) {
19 const k = Math.max(0, Math.min(1, t))
20 const m = (s: number) => Math.round(ch(a, s) + (ch(b, s) - ch(a, s)) * k)
21 return rgb(m(16), m(8), m(0))
22}
23hooks/people.ts 87 lines1import type { City } from '../types'
2import { hash, mix } from './pixels'
3import type { Weather } from './weather'
4
5export type Street = {
6 W: number
7 ground: number
8 now: number
9 isNight: boolean
10 shade: (col: number) => number
11 set: (x: number, y: number, col: number) => void
12}
13
14const CLOTHES = [0xd94040, 0x3f6fd9, 0x40a060, 0xe0a030, 0x9a50c0, 0x2a2a35, 0xe8e8e8, 0x4fa3a3, 0xd060a0]
15const SKIN = [0xf2d0b0, 0xd8a880, 0xb07850, 0x7a5030, 0x5a3a24]
16const UMBRELLAS = [0xd94040, 0x3f6fd9, 0xf2d24a, 0x40a060, 0x2a2a35, 0xff70b0]
17const MAX_WALKERS = 40
18const COMMUTERS = 4
19
20const pick = (list: number[], roll: number) => list[Math.floor(roll * list.length)] ?? list[0] ?? 0
21
22type Figure = { x: number; seed: number; isMoving: boolean; dir: number; secs: number }
23
24function drawFigure(st: Street, w: Weather, f: Figure) {
25 const { ground, set, shade } = st
26 const body = shade(pick(CLOTHES, hash(f.seed, 1)))
27 const legs = mix(body, 0x101018, 0.45)
28 const step = f.isMoving && Math.floor(f.secs * 4 + f.seed) % 2 === 1 ? f.dir : 0
29 set(f.x, ground, shade(pick(SKIN, hash(f.seed, 2))))
30 set(f.x, ground + 1, body)
31 set(f.x + step, ground + 2, legs)
32
33 const isRaining = (w.kind === 'rain' || w.kind === 'storm') && w.intensity > 0.2
34 if (isRaining && hash(f.seed, 3) < 0.65) {
35 const brolly = shade(pick(UMBRELLAS, hash(f.seed, 4)))
36 for (let dx = -1; dx <= 1; dx++) set(f.x + dx, ground - 1, brolly)
37 }
38 if (hash(f.seed, 5) < 0.12) set(f.x + f.dir * 2, ground + 2, shade(hash(f.seed, 6) < 0.5 ? 0x8a5a3a : 0xe8e0d0))
39}
40
41/** Pedestrians on the pavement, drawn in screen space like the traffic. */
42export function drawPeople(st: Street, c: City, w: Weather, entrances: number[], off: number) {
43 const { W, now, isNight } = st
44 const secs = now / 1000
45 const isWet = w.kind === 'rain' || w.kind === 'storm'
46 const weatherFactor = { clear: 1, cloudy: 0.95, fog: 0.8, rain: 0.7, storm: 0.4, snow: 0.6 }[w.kind]
47 const outside = (isNight ? 0.35 : 1) * (1 - (1 - weatherFactor) * w.intensity)
48 const crowd = Math.min(MAX_WALKERS, Math.max(1, Math.round(c.citizens / 6)))
49 const hurry = isWet && w.intensity > 0.2 ? 1.6 : 1
50 const loop = W + 6
51
52 for (let i = 0; i < crowd; i++) {
53 if (hash(i, 11) > outside) continue
54 const seed = i * 7 + 3
55 const dir = hash(i, 12) < 0.5 ? 1 : -1
56 const speed = (2 + hash(i, 13) * 3) * hurry
57 const cycle = 10 + hash(i, 14) * 8
58 const pause = hash(i, 15) < 0.5 ? 1.5 + hash(i, 16) * 2.5 : 0
59 const walking = cycle - pause
60 const inCycle = secs % cycle
61 const walked = Math.floor(secs / cycle) * walking + Math.min(inCycle, walking)
62 const pos = (hash(i, 17) * loop + walked * speed) % loop
63 const x = Math.round(dir > 0 ? pos - 3 : W + 2 - pos)
64 drawFigure(st, w, { x, seed, isMoving: inCycle < walking, dir, secs })
65 }
66
67 entrances.forEach((ex, s) => {
68 const period = 22 + hash(s, 21) * 10
69 const since = (secs + hash(s, 22) * period) % period
70 if (since > 7) return
71 for (let k = 0; k < COMMUTERS; k++) {
72 const delay = k * 0.7
73 if (since < delay) continue
74 const dir = hash(s, k, 23) < 0.5 ? 1 : -1
75 const x = Math.round(ex + off + dir * (since - delay) * (2.5 + hash(s, k, 24) * 1.5))
76 drawFigure(st, w, { x, seed: 1000 + s * 10 + k, isMoving: true, dir, secs })
77 }
78 })
79
80 if (now < c.fireworksUntil) {
81 for (let k = 0; k < 10; k++) {
82 const x = Math.round(hash(k, 31) * W)
83 drawFigure(st, w, { x, seed: 2000 + k, isMoving: false, dir: 1, secs })
84 }
85 }
86}
87hooks/specials.ts 624 lines1import type { Building, City } from '../types'
2import type { Canvas } from './buildings'
3import { hash, mix } from './pixels'
4import { owns } from './shop'
5
6const GONDOLAS = [0xd94040, 0x3f6fd9, 0xf2d24a, 0x40a060, 0x9a50c0, 0xff9f43]
7
8export function drawSpecial(cv: Canvas, b: Building, x0: number) {
9 const yb = cv.ground - 1
10 if (b.floors < b.target) {
11 for (let x = x0; x < x0 + b.w; x++) {
12 cv.set(x, yb, (x - x0) % 2 ? 0xf2b33d : 0x2a2a2a)
13 if ((x - x0) % 3 === 0) cv.set(x, yb - 1, 0xf2b33d)
14 }
15 cv.fx(x0 + Math.floor(b.w / 2), yb - 2 - (Math.floor(cv.now / 500) % 2), 0xf2b33d)
16 return
17 }
18 if (b.special === 'plaza') drawPlaza(cv, b, x0, yb)
19 else if (b.special === 'ferris') drawFerris(cv, b, x0, yb)
20 else if (b.special === 'stadium') drawStadium(cv, b, x0, yb)
21 else if (b.special === 'needle') drawNeedle(cv, x0, yb)
22 else if (b.special === 'rocket') drawRocket(cv, b, x0, yb)
23 else if (b.special === 'biodome') drawBiodome(cv, b, x0, yb)
24 else if (b.special === 'supertall') drawSupertall(cv, b, x0, yb)
25 else if (b.special === 'elevator') drawElevator(cv, x0, yb)
26 else if (b.special === 'lighthouse') drawLighthouse(cv, x0, yb)
27 else if (b.special === 'turbine') drawTurbine(cv, b, x0, yb)
28 else if (b.special === 'castle') drawCastle(cv, b, x0, yb)
29 else if (b.special === 'pyramid') drawPyramid(cv, b, x0, yb)
30}
31
32function drawLighthouse(cv: Canvas, x0: number, yb: number) {
33 const { W, set, fx, get, shade, now, isNight } = cv
34 const mid = x0 + 2
35 const top = yb - 11
36 for (let h = 0; h < 10; h++) {
37 const half = h < 4 ? 2 : 1
38 for (let x = mid - half; x <= mid + half; x++) set(x, yb - h, shade(Math.floor(h / 2) % 2 ? 0xd94040 : 0xf2f2f2))
39 }
40 set(mid - 1, top + 1, shade(0x3a3a48))
41 set(mid + 1, top + 1, shade(0x3a3a48))
42 set(mid, top + 1, isNight ? 0xfff4b0 : shade(0xf2d24a))
43 for (let x = mid - 1; x <= mid + 1; x++) set(x, top, shade(0xd94040))
44 set(mid, top - 1, shade(0x3a3a48))
45 if (!isNight) return
46 // The beam sweeps out one side, then the other, fading with distance.
47 const sweep = Math.sin(now / 1400)
48 const dir = sweep >= 0 ? 1 : -1
49 const reach = Math.round(Math.abs(sweep) * 22)
50 for (let k = 2; k <= reach; k++) {
51 const x = mid + dir * k
52 const y = top + 1 - Math.round(k / 8)
53 if (x >= 0 && x < W && y >= 0) fx(x, y, mix(get(x, y), 0xfff4b0, 0.55 * (1 - k / 24)))
54 }
55}
56
57function drawTurbine(cv: Canvas, b: Building, x0: number, yb: number) {
58 const { fx, set, shade, now, isNight } = cv
59 const mid = x0 + Math.floor(b.w / 2)
60 const hub = yb - 12
61 for (let y = yb; y > hub; y--) set(mid, y, shade(0xe8ecf0))
62 set(mid - 1, yb, shade(0x9aa4b2))
63 set(mid + 1, yb, shade(0x9aa4b2))
64 const turn = now / 900 + b.id
65 for (let k = 0; k < 3; k++) {
66 const ang = turn + (k * Math.PI * 2) / 3
67 for (let d = 1; d <= 4; d++) fx(Math.round(mid + Math.cos(ang) * d), Math.round(hub + Math.sin(ang) * d), shade(0xf2f2f2))
68 }
69 fx(mid, hub, isNight && Math.floor(now / 900) % 2 ? 0xff3030 : shade(0xc8ccd4))
70}
71
72function drawCastle(cv: Canvas, b: Building, x0: number, yb: number) {
73 const { set, fx, shade, now, isNight } = cv
74 const stone = (x: number, y: number) => shade(hash(x, y, 51) > 0.8 ? 0x8a8478 : 0xa8a294)
75 const x1 = x0 + b.w - 1
76 const mid = x0 + Math.floor(b.w / 2)
77 for (let x = x0; x <= x1; x++) for (let h = 0; h < 4; h++) set(x, yb - h, stone(x, h))
78 for (let x = x0 + 2; x <= x1 - 2; x += 2) set(x, yb - 4, stone(x, 4))
79 for (const tx of [x0, x1 - 1]) {
80 for (let x = tx; x <= tx + 1; x++) for (let h = 4; h < 7; h++) set(x, yb - h, stone(x, h))
81 set(tx, yb - 7, stone(tx, 7))
82 set(tx + 1, yb - 5, isNight ? 0xffd27a : shade(0x3a3a48))
83 }
84 for (let x = mid - 1; x <= mid + 1; x++) for (let h = 4; h < 8; h++) set(x, yb - h, stone(x, h))
85 set(mid - 1, yb - 8, stone(mid - 1, 8))
86 set(mid + 1, yb - 8, stone(mid + 1, 8))
87 set(mid, yb - 6, isNight ? 0xffd27a : shade(0x3a3a48))
88 set(mid, yb, shade(0x3a2a1a))
89 set(mid, yb - 1, shade(0x3a2a1a))
90 for (let y = yb - 8; y > yb - 12; y--) set(mid, y, shade(0x6a6f78))
91 const wave = Math.floor(now / 300) % 2
92 fx(mid + 1, yb - 11, shade(0xd94040))
93 fx(mid + 2, yb - 11 + wave, shade(0xd94040))
94 fx(mid + 1, yb - 10, shade(0xb03030))
95}
96
97function drawPyramid(cv: Canvas, b: Building, x0: number, yb: number) {
98 const { W, set, fx, get, shade, now, isNight } = cv
99 const mid = x0 + Math.floor(b.w / 2)
100 const rows = Math.ceil(b.w / 2)
101 for (let r = 0; r < rows; r++) {
102 const half = Math.floor(b.w / 2) - r
103 for (let x = mid - half; x <= mid + half; x++) {
104 const isEdge = x === mid - half || x === mid + half
105 const pane = isNight ? mix(0x1a2a3a, 0xffe2a0, hash(x, r, Math.floor(now / 5000)) > 0.55 ? 0.7 : 0.1) : mix(0x9fc8e8, 0xffffff, hash(x, r) * 0.35)
106 set(x, yb - r, isEdge || r % 3 === 0 ? shade(0x6a7a90) : shade(pane))
107 }
108 }
109 if (!isNight) return
110 for (let y = yb - rows; y >= 0; y--) if (mid >= 0 && mid < W) fx(mid, y, mix(get(mid, y), 0xe8f4ff, 0.45 * (y / (yb - rows + 1)) + 0.1))
111}
112
113function drawBiodome(cv: Canvas, b: Building, x0: number, yb: number) {
114 const { set, shade, now, isNight } = cv
115 const cx = x0 + Math.floor(b.w / 2)
116 const r = Math.floor(b.w / 2)
117 for (let x = x0; x < x0 + b.w; x++) set(x, yb, shade(0x9aa4b2))
118 for (let dy = 1; dy <= r; dy++) {
119 const half = Math.round(Math.sqrt(r * r - (dy - 1) * (dy - 1)))
120 for (let x = cx - half; x <= cx + half; x++) {
121 const isEdge = x === cx - half || x === cx + half || dy === r
122 const isMullion = (x - cx) % 3 === 0 || dy % 3 === 0
123 const inside = dy <= 3 && hash(x, dy, 41) > 0.35 ? (hash(x, dy, 42) > 0.8 ? 0xe86a8a : 0x3f9a4a) : 0x8fd8e8
124 const glass = isNight ? mix(inside === 0x8fd8e8 ? 0x1a3a3a : inside, 0x7fffb0, 0.25 + 0.1 * Math.sin(now / 900 + x)) : mix(inside, 0xffffff, 0.25)
125 set(x, yb - dy, isEdge || isMullion ? shade(0xd0dce4) : shade(glass))
126 }
127 }
128}
129
130function drawSupertall(cv: Canvas, b: Building, x0: number, yb: number) {
131 const { set, fx, shade, now, isNight } = cv
132 const height = Math.max(12, yb - 2)
133 const tiers = [
134 { w: b.w, upTo: 0.42 },
135 { w: b.w - 2, upTo: 0.7 },
136 { w: b.w - 4, upTo: 0.88 },
137 { w: b.w - 6, upTo: 0.94 },
138 ]
139 const mid = x0 + Math.floor(b.w / 2)
140 for (let h = 0; h < height * 0.94; h++) {
141 const tier = tiers.find(t => h < height * t.upTo) ?? tiers[tiers.length - 1]!
142 const half = Math.floor(tier.w / 2)
143 const y = yb - h
144 for (let x = mid - half; x <= mid + half; x++) {
145 const isEdge = x === mid - half || x === mid + half
146 const lit = isNight ? (hash(x, h, Math.floor(now / 7000)) > 0.4 ? 0xffe2a0 : 0x1e2838) : mix(0x9fc8e8, 0xffffff, hash(x, h) * 0.3)
147 set(x, y, isEdge ? shade(0x5a6a80) : h % 2 ? lit : shade(0x3a4658))
148 }
149 }
150 const crownY = yb - Math.floor(height * 0.94)
151 const crown = isNight ? [0xff4fd8, 0x4ff0ff, 0xfff04f, 0x7fff4f][Math.floor(now / 1500) % 4]! : shade(0xd8dce4)
152 for (let x = mid - 1; x <= mid + 1; x++) set(x, crownY, crown)
153 for (let y = crownY - 1; y >= Math.max(0, yb - height); y--) set(mid, y, shade(0xc8ccd4))
154 fx(mid, Math.max(0, yb - height), Math.floor(now / 700) % 2 ? 0xff3030 : shade(0xc8ccd4))
155}
156
157function drawElevator(cv: Canvas, x0: number, yb: number) {
158 const { set, fx, shade, now, isNight } = cv
159 const mid = x0 + 2
160 for (let r = 0; r < 3; r++) for (let x = x0 + r; x < x0 + 5 - r; x++) set(x, yb - r, shade(r === 1 ? 0x6a7a90 : 0x8a96a8))
161 for (let y = yb - 3; y >= 0; y--) set(mid, y, mix(cv.get(mid, y), 0xe8ecf0, 0.7))
162 const trip = (now / 1000) % 40
163 const rise = trip < 20 ? trip / 20 : 2 - trip / 20
164 const cy = Math.round(yb - 4 - rise * (yb - 6))
165 fx(mid - 1, cy, shade(0xd8dce4))
166 fx(mid + 1, cy, shade(0xd8dce4))
167 fx(mid, cy, isNight || Math.floor(now / 400) % 2 ? 0x7fd8ff : shade(0xd8dce4))
168}
169
170function drawPlaza(cv: Canvas, b: Building, x0: number, yb: number) {
171 const { set, fx, shade, now } = cv
172 for (let x = x0; x < x0 + b.w; x++) set(x, yb, shade((x - x0) % 2 ? 0xd8d0c0 : 0xc8c0b0))
173 const mid = x0 + Math.floor(b.w / 2)
174 for (let x = mid - 2; x <= mid + 2; x++) set(x, yb - 1, shade(0x9aa4b2))
175 set(mid - 1, yb - 1, 0x4f9fd9)
176 set(mid, yb - 1, 0x4f9fd9)
177 set(mid + 1, yb - 1, 0x4f9fd9)
178 const jet = 2 + Math.round((Math.sin(now / 300) + 1) * 1.2)
179 for (let y = 2; y <= jet + 1; y++) fx(mid, yb - y, mix(0x9fd8ff, 0xffffff, y / (jet + 2)))
180 const spray = Math.floor(now / 200) % 2
181 fx(mid - 1 - spray, yb - jet, 0xbfe6ff)
182 fx(mid + 1 + spray, yb - jet, 0xbfe6ff)
183 set(x0, yb - 1, shade(0x6b4a2b))
184 set(x0 + b.w - 1, yb - 1, shade(0x6b4a2b))
185}
186
187function drawFerris(cv: Canvas, b: Building, x0: number, yb: number) {
188 const { set, fx, shade, now, isNight } = cv
189 const cx = x0 + Math.floor(b.w / 2)
190 const r = Math.floor(b.w / 2) - 1
191 const cy = yb - r - 2
192 const frame = shade(0xe8e8e8)
193 for (let y = cy; y <= yb; y++) {
194 const spread = Math.round(((y - cy) / (yb - cy)) * (r - 1))
195 set(cx - spread, y, shade(0x9aa4b2))
196 set(cx + spread, y, shade(0x9aa4b2))
197 }
198 for (let a = 0; a < 48; a++) {
199 const ang = (a / 48) * Math.PI * 2
200 set(Math.round(cx + Math.cos(ang) * r), Math.round(cy + Math.sin(ang) * r), frame)
201 }
202 const turn = now / 6000
203 for (let s = 0; s < 4; s++) {
204 const ang = turn + (s / 4) * Math.PI
205 for (let d = 1; d < r; d++) fx(Math.round(cx + Math.cos(ang) * d), Math.round(cy + Math.sin(ang) * d), shade(0xb0b8c4))
206 }
207 for (let g = 0; g < 8; g++) {
208 const ang = turn + (g / 8) * Math.PI * 2
209 const gx = Math.round(cx + Math.cos(ang) * r)
210 const gy = Math.round(cy + Math.sin(ang) * r) + 1
211 const col = GONDOLAS[g % GONDOLAS.length] ?? 0xd94040
212 fx(gx, gy, isNight ? (Math.floor(now / 400 + g) % 3 ? col : 0xffffff) : shade(col))
213 }
214 set(cx, cy, shade(0x707070))
215}
216
217function drawStadium(cv: Canvas, b: Building, x0: number, yb: number) {
218 const { set, fx, shade, now, isNight } = cv
219 const h = 5
220 for (let r = 0; r < h; r++) {
221 const inset = Math.max(0, r - 2)
222 for (let x = x0 + inset; x < x0 + b.w - inset; x++) {
223 const isStand = r >= 1 && r <= 3 && x > x0 + inset && x < x0 + b.w - inset - 1
224 const fan = GONDOLAS[Math.floor(hash(x, r, Math.floor(now / 600)) * GONDOLAS.length)] ?? 0xd94040
225 set(x, yb - r, isStand ? shade(hash(x, r) > 0.35 ? fan : 0x5a6070) : shade(0x8a909c))
226 }
227 }
228 for (let x = x0 + 3; x < x0 + b.w - 3; x++) set(x, yb - h, shade(0xe8e8e8))
229 for (const px of [x0, x0 + b.w - 1]) {
230 for (let y = yb; y > yb - 9; y--) set(px, y, shade(0x707680))
231 const head = isNight ? 0xffffe0 : shade(0xd0d4dc)
232 fx(px, yb - 9, head)
233 fx(px + (px === x0 ? 1 : -1), yb - 9, head)
234 if (isNight)
235 for (let k = 1; k < 5; k++) {
236 const bx = px + (px === x0 ? k : -k)
237 const by = yb - 9 + k
238 fx(bx, by, mix(cv.get(Math.max(0, Math.min(cv.W - 1, bx)), Math.max(0, by)), 0xffffe0, 0.3))
239 }
240 }
241}
242
243function drawNeedle(cv: Canvas, x0: number, yb: number) {
244 const { set, shade, now, isNight } = cv
245 const mid = x0 + 2
246 const top = Math.max(3, Math.round(cv.ground * 0.2))
247 const pod = top + 3
248 for (let y = yb; y > top; y--) {
249 set(mid, y, shade(0xc8ccd4))
250 if (y > yb - 4) {
251 set(mid - 1, y, shade(0x9aa4b2))
252 set(mid + 1, y, shade(0x9aa4b2))
253 }
254 }
255 for (let x = x0; x < x0 + 5; x++) set(x, pod, shade(0xd8dce4))
256 for (let x = x0; x < x0 + 5; x++) set(x, pod + 1, isNight ? (hash(x, Math.floor(now / 1500)) > 0.3 ? 0xffd27a : 0x3a3a48) : shade(0x8fd0f0))
257 for (let x = x0 + 1; x < x0 + 4; x++) set(x, pod + 2, shade(0xd8dce4))
258 set(mid, top, isNight && Math.floor(now / 700) % 2 ? 0xff3030 : shade(0xe8e8e8))
259}
260
261const LAUNCH_EVERY = 180_000
262
263function drawRocket(cv: Canvas, b: Building, x0: number, yb: number) {
264 const { set, fx, shade, now } = cv
265 for (let x = x0; x < x0 + b.w; x++) set(x, yb, shade(0x6a6f78))
266 const gantry = x0 + 1
267 for (let y = yb - 1; y > yb - 11; y--) {
268 set(gantry, y, shade(0xd94040))
269 if (y % 2 === 0) set(gantry + 1, y, shade(0x9a3030))
270 }
271 const t = (now + b.id * 7919) % LAUNCH_EVERY
272 const rx = x0 + 4
273 const climbing = t > 150_000 && t < 168_000
274 const gone = t >= 168_000
275 if (gone) return
276 const lift = climbing ? Math.round(((t - 150_000) / 1000) ** 2 * 0.4) : 0
277 const base = yb - 1 - lift
278 for (let y = base; y > base - 8; y--) {
279 const col = y <= base - 6 ? 0xd94040 : 0xf2f2f2
280 fx(rx, y, shade(col))
281 if (y > base - 6) fx(rx + 1, y, shade(0xd8dce4))
282 }
283 fx(rx - 1, base, shade(0x9aa4b2))
284 fx(rx + 2, base, shade(0x9aa4b2))
285 if (climbing || t > 140_000) {
286 const flicker = Math.floor(now / 80) % 2
287 fx(rx, base + 1, flicker ? 0xffe14a : 0xff8a30)
288 fx(rx + 1, base + 1, flicker ? 0xff8a30 : 0xffe14a)
289 if (climbing) {
290 fx(rx, base + 2, 0xff5a30)
291 for (let y = base + 3; y <= yb; y++) fx(rx + Math.round(Math.sin(y + now / 200)), y, mix(cv.get(Math.max(0, rx), Math.max(0, y)), 0xd8d8d8, 0.6))
292 }
293 }
294}
295
296const FLOWERS = [0xe86a8a, 0xf2d24a, 0xffffff, 0x9a50c0, 0xff8a30]
297const BUNTING = [0xd94040, 0xf2d24a, 0x3f6fd9, 0x40a060]
298
299/** One truck, parked a little way along the street, with steam drifting up from its grill. */
300function drawFoodTruck(cv: Canvas, off: number) {
301 const { fx, get, shade, now, isNight, ground } = cv
302 const x0 = off + 14
303 for (let dx = 0; dx < 7; dx++) {
304 fx(x0 + dx, ground - 1, shade(0xf2f2f2))
305 fx(x0 + dx, ground - 2, shade(dx === 2 || dx === 3 ? (isNight ? 0xffe2a0 : 0x3a3a48) : 0xf2f2f2))
306 if (dx < 5) fx(x0 + dx, ground - 3, shade(0xf2b33d))
307 }
308 fx(x0 + 1, ground, shade(0x2a2a2a))
309 fx(x0 + 5, ground, shade(0x2a2a2a))
310 const rise = (now / 300) % 4
311 const sx = x0 + 1 + Math.round(Math.sin(now / 500))
312 const sy = Math.round(ground - 4 - rise)
313 if (sx >= 0 && sx < cv.W && sy >= 0) fx(sx, sy, mix(get(sx, sy), 0xe8e8e8, 0.6 * (1 - rise / 4)))
314}
315
316/** Trees, lamps and the rest of the street furniture along the pavement, in world space so they pan with the city. */
317export function drawStreetscape(cv: Canvas, c: City, off: number, season: string) {
318 const { W, set, fx, shade, ground, isNight } = cv
319 const hasTrees = owns(c, 'trees')
320 const hasLamps = owns(c, 'lamps')
321 const hasFlowers = owns(c, 'flowers')
322 const hasBenches = owns(c, 'benches')
323 const hasBunting = owns(c, 'bunting')
324 if (owns(c, 'foodtruck')) drawFoodTruck(cv, off)
325 if (!hasTrees && !hasLamps && !hasFlowers && !hasBenches && !hasBunting) return
326 const leaf = { winter: 0, spring: 0x5fb85a, summer: 0x2f7d3a, autumn: 0xd8782a }[season] ?? 0x2f7d3a
327 for (let x = 0; x < W; x++) {
328 const wx = x - off
329 const slot = ((wx % 12) + 12) % 12
330 if (hasTrees && slot === 3) {
331 set(x, ground, shade(0x6b4a2b))
332 fx(x, ground - 1, shade(0x6b4a2b))
333 if (leaf === 0) {
334 fx(x - 1, ground - 2, shade(0x6b4a2b))
335 fx(x + 1, ground - 3, shade(0x6b4a2b))
336 fx(x, ground - 3, shade(0x6b4a2b))
337 } else
338 for (let dx = -1; dx <= 1; dx++) for (let dy = 2; dy <= 3; dy++) fx(x + dx, ground - dy, shade(hash(wx, dx, dy) > 0.8 ? mix(leaf, 0xffffff, 0.2) : leaf))
339 }
340 if (hasFlowers && (slot === 5 || slot === 6)) {
341 fx(x, ground - 1, shade(0x3f8a3a))
342 fx(x, ground - 2, shade(FLOWERS[Math.floor(hash(wx, 31) * FLOWERS.length)] ?? 0xe86a8a))
343 }
344 const bench = ((wx % 36) + 36) % 36
345 if (hasBenches && bench < 3) {
346 const at = bench
347 fx(x, ground - 1, shade(0x8a5a3a))
348 if (at !== 1) fx(x, ground, shade(0x4a4e58))
349 if (at === 0) fx(x, ground - 2, shade(0x8a5a3a))
350 }
351 if (hasBunting) {
352 const sag = Math.round(Math.sin((slot / 12) * Math.PI) * 1.5)
353 const y = ground - 9 + sag + (slot % 2 ? 0 : Math.round(Math.sin(cv.now / 600 + wx) * 0.4))
354 fx(x, y, shade(0x6a6f78))
355 if (slot % 2 === 1) fx(x, y + 1, shade(BUNTING[((Math.floor(wx / 2) % BUNTING.length) + BUNTING.length) % BUNTING.length] ?? 0xd94040))
356 }
357 if (hasLamps && slot === 9) {
358 for (let y = ground; y > ground - 4; y--) fx(x, y, shade(0x4a4e58))
359 fx(x + 1, ground - 4, shade(0x4a4e58))
360 fx(x + 1, ground - 3, isNight ? 0xfff0b0 : shade(0xd0d4dc))
361 if (isNight)
362 for (let dx = -1; dx <= 3; dx++)
363 for (let y = ground; y < ground + 3; y++) {
364 const px = x + dx
365 if (px >= 0 && px < W) set(px, y, mix(cv.get(px, y), 0xfff0b0, 0.18))
366 }
367 }
368 }
369}
370
371/** The balloon and the airport's planes, in screen space. */
372export function drawSkyline(cv: Canvas, c: City, off: number) {
373 const { W, fx, shade, now, isNight, ground } = cv
374 const secs = now / 1000
375
376 if (owns(c, 'birds') && !isNight) drawBirds(cv)
377 if (owns(c, 'kites')) drawKites(cv)
378 if (owns(c, 'ufo')) drawUfo(cv)
379
380 if (owns(c, 'balloon')) {
381 const span = W + 12
382 const x = Math.round(((secs * 1.2) % span) - 6)
383 const y = Math.round(ground * 0.22 + Math.sin(secs / 3) * 2)
384 const stripes = [0xd94040, 0xf2d24a, 0x3f6fd9]
385 for (let dy = 0; dy < 5; dy++) {
386 const half = dy === 0 || dy === 4 ? 1 : 2
387 for (let dx = -half; dx <= half; dx++) fx(x + dx, y + dy, shade(stripes[(dx + 3) % 3] ?? 0xd94040))
388 }
389 fx(x - 1, y + 5, shade(0x6b4a2b))
390 fx(x + 1, y + 5, shade(0x6b4a2b))
391 fx(x, y + 6, shade(0x8a5a3a))
392 if (isNight) fx(x, y + 4, 0xffb040)
393 }
394
395 if (owns(c, 'airport')) {
396 const period = 45
397 const t = secs % period
398 const flight = Math.floor(secs / period)
399 const dir = hash(flight, 61) < 0.5 ? 1 : -1
400 const speed = 14
401 const travelled = t * speed
402 if (travelled < W + 30) {
403 const x = Math.round(dir > 0 ? travelled - 8 : W + 8 - travelled)
404 const y = 1 + Math.round(hash(flight, 62) * 3)
405 for (let k = 1; k < 18; k++) {
406 const tx = x - dir * (k + 3)
407 if (tx >= 0 && tx < W) fx(tx, y, mix(cv.get(tx, y), 0xffffff, 0.5 * (1 - k / 18)))
408 }
409 for (let k = 0; k < 4; k++) fx(x - dir * k, y, shade(0xe8ecf0))
410 fx(x - dir * 3, y - 1, shade(0xd8dce4))
411 fx(x - dir, y + 1, shade(0xb8bcc4))
412 const blink = Math.floor(now / 500) % 2
413 if (isNight || blink) fx(x, y, blink ? 0xff3030 : 0x40ff60)
414 }
415 }
416
417 if (owns(c, 'airship')) {
418 const span = W + 24
419 const x = Math.round(W + 12 - ((secs * 0.8 + 40) % span))
420 const y = Math.round(ground * 0.12 + 2 + Math.sin(secs / 5) * 1)
421 const hull = shade(0xc8ccd4)
422 for (let dx = -6; dx <= 6; dx++) {
423 const half = Math.abs(dx) >= 6 ? 0 : Math.abs(dx) >= 4 ? 1 : 2
424 for (let dy = -half; dy <= half; dy++) fx(x + dx, y + dy, dy === half ? mix(hull, 0x000000, 0.2) : hull)
425 }
426 fx(x + 7, y - 1, shade(0x9aa4b2))
427 fx(x + 7, y + 1, shade(0x9aa4b2))
428 fx(x - 1, y + 3, shade(0x6a6f78))
429 fx(x, y + 3, shade(0x6a6f78))
430 if (isNight)
431 for (let dx = -4; dx <= 3; dx++) fx(x + dx, y, (Math.floor(now / 250) + dx) % 3 === 0 ? 0xffe14a : 0xff4fd8)
432 }
433
434 if (owns(c, 'monorail')) {
435 const track = Math.max(4, ground - 11)
436 const reach = c.buildings.reduce((m, b) => Math.max(m, b.x + b.w), 0)
437 const from = Math.max(0, off)
438 const to = Math.min(W, off + reach)
439 for (let x = from; x < to; x++) {
440 fx(x, track, shade(0x9aa4b2))
441 const wx = x - off
442 if (wx % 24 === 2 || x === from || x === to - 1) for (let y = track + 1; y < ground; y++) fx(x, y, shade(0x7a8494))
443 }
444 const len = 10
445 const span = Math.max(1, reach - len)
446 const travel = (secs * 9) % (span * 2)
447 const start = off + Math.round(travel < span ? travel : span * 2 - travel)
448 for (let k = 0; k < len; k++) {
449 const x = start + k
450 if (x < from || x >= to) continue
451 fx(x, track - 1, k === len - 1 ? (isNight ? 0xfff4b0 : shade(0xffffff)) : k % 2 ? (isNight ? 0xffe2a0 : 0x3f8fe0) : shade(0xf2f2f2))
452 fx(x, track - 2, shade(0xf2f2f2))
453 }
454 }
455}
456
457function drawBirds(cv: Canvas) {
458 const { W, fx, shade, now, ground } = cv
459 const secs = now / 1000
460 const period = 40
461 const flock = Math.floor(secs / period)
462 const x = Math.round(((secs % period) / period) * (W + 30)) - 15
463 const y0 = Math.round(ground * (0.15 + hash(flock, 31) * 0.25))
464 for (let k = 0; k < 5; k++) {
465 const bx = x - Math.abs(k - 2) * 3
466 const by = y0 + Math.abs(k - 2) * 2 + (k % 2)
467 const flap = Math.floor(now / 250 + k) % 2
468 fx(bx, by, shade(0x2a2a3a))
469 fx(bx - 1, by - flap, shade(0x2a2a3a))
470 fx(bx + 1, by - flap, shade(0x2a2a3a))
471 }
472}
473
474const KITE_COLOURS = [0xd94040, 0xf2d24a, 0x3f6fd9, 0x9a50c0]
475
476function drawKites(cv: Canvas) {
477 const { W, fx, shade, now, ground } = cv
478 const secs = now / 1000
479 for (let k = 0; k < 3; k++) {
480 const x = Math.round(((k + 0.5) * W) / 3 + Math.sin(secs / 2.3 + k * 2) * 4)
481 const y = Math.round(ground * 0.3 + k * 2 + Math.sin(secs / 1.7 + k) * 2)
482 const col = shade(KITE_COLOURS[k % KITE_COLOURS.length] ?? 0xd94040)
483 fx(x, y - 1, col)
484 fx(x - 1, y, col)
485 fx(x, y, shade(0xf2f2f2))
486 fx(x + 1, y, col)
487 fx(x, y + 1, col)
488 for (let t = 2; t <= 5; t++) fx(x + Math.round(Math.sin(secs * 3 + t + k) * 0.8), y + t, t % 2 ? col : shade(0xf2f2f2))
489 }
490}
491
492const UFO_EVERY = 150
493
494function drawUfo(cv: Canvas) {
495 const { W, fx, get, shade, now, isNight, ground } = cv
496 const secs = now / 1000
497 const t = secs % UFO_EVERY
498 if (t > 30) return
499 const visit = Math.floor(secs / UFO_EVERY)
500 // Swoops in from one side, hovers, then leaves the way it came.
501 const home = Math.round(W * (0.25 + hash(visit, 71) * 0.5))
502 const from = hash(visit, 72) < 0.5 ? -8 : W + 8
503 const p = t < 6 ? t / 6 : t > 24 ? (30 - t) / 6 : 1
504 const x = Math.round(from + (home - from) * p + (p === 1 ? Math.sin(secs * 1.5) * 2 : 0))
505 const y = Math.round(ground * 0.25 + Math.sin(secs * 2) * 0.6)
506 if (p === 1 && t > 9 && t < 21)
507 for (let by = y + 2; by < ground; by++) {
508 const spread = Math.floor((by - y) / 3)
509 for (let dx = -spread; dx <= spread; dx++) {
510 const bx = x + dx
511 if (bx >= 0 && bx < W) fx(bx, by, mix(get(bx, by), 0x9fffb0, isNight ? 0.3 : 0.18))
512 }
513 }
514 for (let dx = -1; dx <= 1; dx++) fx(x + dx, y - 1, shade(0x7fd8ff))
515 for (let dx = -3; dx <= 3; dx++) fx(x + dx, y, shade(0x9aa4b2))
516 for (let dx = -2; dx <= 2; dx++) fx(x + dx, y + 1, (Math.floor(now / 200) + dx) % 3 === 0 ? 0xffe14a : shade(0x6a6f78))
517}
518
519/** Things beyond the clouds: drawn behind the city. */
520export function drawHeavens(
521 cv: Canvas,
522 c: City,
523 light: number,
524 moon: { x: number; y: number } | undefined,
525) {
526 const { W, fx, get, now, ground } = cv
527 const secs = now / 1000
528 const glow = 1 - light * 0.6
529
530 if (owns(c, 'meteors') && light < 0.5) {
531 // A few streaks a minute, each a bright head with a fading tail, falling left to right.
532 for (let k = 0; k < 3; k++) {
533 const period = 7 + k * 3
534 const shot = Math.floor(secs / period)
535 const t = (secs % period) / 1.2
536 if (t > 1) continue
537 const sx = Math.round(hash(shot, k, 81) * W)
538 const sy = Math.round(hash(shot, k, 82) * ground * 0.35)
539 const len = 9
540 const hx = sx + Math.round(t * 14)
541 const hy = sy + Math.round(t * 6)
542 for (let j = 0; j < len; j++) {
543 const x = hx - Math.round((j * 14) / 18)
544 const y = hy - Math.round((j * 6) / 18)
545 if (x >= 0 && x < W && y >= 0 && y < ground) fx(x, y, mix(get(x, y), 0xffffff, (1 - j / len) * (1 - light * 2) * (1 - t * 0.5)))
546 }
547 }
548 }
549
550 if (owns(c, 'ring')) {
551 for (let x = 0; x < W; x++) {
552 const t = (x - W / 2) / (W * 0.62)
553 const y = Math.round(ground * 0.42 - (1 - t * t) * ground * 0.34)
554 if (y < 0 || y >= ground) continue
555 fx(x, y, mix(get(x, y), 0xffe8a8, 0.55 * glow))
556 if (y + 1 < ground) fx(x, y + 1, mix(get(x, y + 1), 0xffd070, 0.2 * glow))
557 if ((x + Math.floor(secs * 6)) % 17 === 0) fx(x, y, mix(get(x, y), 0xffffff, 0.9))
558 }
559 }
560
561 if (owns(c, 'station')) {
562 const period = 90
563 const t = (secs % period) / period
564 const x = Math.round(t * (W + 10) - 5)
565 const y = 3
566 const at = (px: number) => get(Math.max(0, Math.min(W - 1, px)), y)
567 const panel = mix(0x3f6fd9, 0xffffff, 0.2 * glow)
568 for (const dx of [-3, -2, 2, 3]) fx(x + dx, y, mix(at(x + dx), panel, 0.5 + 0.4 * glow))
569 fx(x - 1, y, mix(at(x - 1), 0xd8dce4, 0.8))
570 fx(x, y, Math.floor(now / 600) % 2 ? 0xffffff : 0xd8dce4)
571 fx(x + 1, y, mix(at(x + 1), 0xd8dce4, 0.8))
572 }
573
574 if (owns(c, 'moonbase') && moon && light < 0.5) {
575 fx(moon.x - 1, moon.y + 1, Math.floor(now / 500) % 2 ? 0x4ff0ff : 0x2a8aa0)
576 fx(moon.x + 1, moon.y, Math.floor(now / 700) % 2 ? 0xffe14a : 0xa08020)
577 const trip = (secs % 60) / 60
578 if (trip < 0.5) {
579 const p = trip < 0.25 ? trip * 4 : (0.5 - trip) * 4
580 const sx = Math.round(W * 0.5 + (moon.x - W * 0.5) * p)
581 const sy = Math.round(ground - 2 + (moon.y + 2 - ground) * p)
582 fx(sx, sy, 0xffffff)
583 fx(sx, sy + 1, 0xff8a30)
584 }
585 }
586}
587
588/** The Dyson swarm, drawn over the clouds: a ring of mirrors round the sun by day, a glinting band by night. */
589export function drawSwarm(cv: Canvas, c: City, sun: { x: number; y: number } | undefined, overcast: number) {
590 if (!owns(c, 'dyson')) return
591 const { W, fx, get, now, ground } = cv
592 const secs = now / 1000
593 const clear = 1 - overcast * 0.6
594 const dot = (x: number, y: number, col: number, a: number) => {
595 if (x >= 0 && x < W && y >= 0 && y < ground) fx(x, y, mix(get(x, y), col, a * clear))
596 }
597
598 if (sun) {
599 const orbit = (ang: number, r: number) => ({ x: Math.round(sun.x + Math.cos(ang) * r), y: Math.round(sun.y + Math.sin(ang) * r * 0.45 + Math.cos(ang) * 1.5) })
600 for (let k = 0; k < 48; k++) {
601 const p = orbit((k / 48) * Math.PI * 2, 8)
602 dot(p.x, p.y, 0xc8e8ff, 0.18)
603 }
604 for (let k = 0; k < 16; k++) {
605 const ang = secs / 6 + (k / 16) * Math.PI * 2
606 const p = orbit(ang, 8 + (k % 3) - 1)
607 const isNear = Math.sin(ang) > 0
608 const isGlint = (Math.floor(now / 250) + k * 5) % 16 === 0
609 dot(p.x, p.y, isGlint ? 0xffffff : 0xa8f0ff, isNear ? 0.95 : 0.45)
610 }
611 return
612 }
613
614 // Night: the mirrors still catch the sun below the horizon, a sweep of light running along their band.
615 const sweep = (secs / 9) % 1.4 - 0.2
616 for (let k = 0; k < 36; k++) {
617 const t = ((k + hash(k, 93)) / 36 + secs / 400) % 1
618 const x = Math.round(t * W)
619 const y = Math.round(ground * (0.1 + t * 0.2) + (hash(k, 94) - 0.5) * 3)
620 const lit = Math.max(0, 1 - Math.abs(t - sweep) * 12)
621 dot(x, y, lit > 0.3 ? 0xffffff : 0x9fdcff, 0.35 + lit * 0.6)
622 }
623}
624hooks/underground.ts 113 lines1import type { City } from '../types'
2import type { Canvas } from './buildings'
3import { hash, mix, STREET } from './pixels'
4import { owns } from './shop'
5
6export const SUBWAY_AT = 80
7export const SECOND_LINE_AT = 300
8export const TRAINS_AT = 24
9const STATION_EVERY = 40
10const LINES = [
11 { color: 0xe04040, firstStation: 14, speed: 13 },
12 { color: 0x3f8fe0, firstStation: 34, speed: 10 },
13]
14
15export const MAX_TUNNEL = 1000
16const tunnelOf = (n: number | undefined) => (Number.isFinite(n) ? Math.min(MAX_TUNNEL, Math.max(0, n ?? 0)) : 0)
17export const lengthsOf = (c: City) => [tunnelOf(c.tunnel), tunnelOf(c.tunnel2)]
18
19export function stationsOf(c: City) {
20 return lengthsOf(c).reduce((n, len, i) => {
21 const first = LINES[i]?.firstStation ?? 0
22 return n + (len >= first + 8 ? Math.floor((len - first - 8) / STATION_EVERY) + 1 : 0)
23 }, 0)
24}
25
26/** World x of each Red Line street entrance. */
27export function entrancesOf(c: City) {
28 const xs: number[] = []
29 const first = LINES[0]?.firstStation ?? 0
30 const [len = 0] = lengthsOf(c)
31 for (let sx = first; sx + 8 <= len; sx += STATION_EVERY) xs.push(sx + 1)
32 return xs
33}
34
35/** Rows the underground needs below the road, given the pixel height on offer. */
36export function depthOf(c: City, H: number) {
37 const [one = 0, two = 0] = lengthsOf(c)
38 if (one <= 0 || H < 28) return 0
39 return two > 0 && H >= 36 ? 12 : 7
40}
41
42export function drawUnderground(cv: Canvas, c: City, off: number, depth: number, reach: number) {
43 const { set, W, now } = cv
44 const top = cv.ground + STREET
45 for (let y = top; y < top + depth; y++)
46 for (let x = 0; x < W; x++) {
47 const wx = x - off
48 const soil = mix(0x5a4030, 0x2e2018, (y - top) / depth)
49 set(x, y, hash(wx, y, 5) > 0.88 ? mix(soil, 0x9a8a70, 0.35) : soil)
50 }
51
52 lengthsOf(c).forEach((len, i) => {
53 const line = LINES[i]
54 if (!line || len <= 0 || (i === 1 && depth < 12)) return
55 const ceiling = top + 1 + i * 5
56 const end = off + len
57
58 for (let x = Math.max(0, off); x < Math.min(W, end); x++) {
59 const wx = x - off
60 set(x, ceiling, 0x3a3a40)
61 set(x, ceiling + 1, wx % 6 === 0 ? 0x6a6040 : 0x1c1c22)
62 set(x, ceiling + 2, 0x1c1c22)
63 set(x, ceiling + 3, wx % 2 ? 0x707070 : 0x4a3a2a)
64 }
65
66 for (let sx = line.firstStation; sx + 8 <= len; sx += STATION_EVERY) {
67 const x0 = sx + off
68 for (let x = x0; x < x0 + 8; x++) {
69 set(x, ceiling, line.color)
70 set(x, ceiling + 1, 0xe8e0c8)
71 set(x, ceiling + 2, 0xd8d0b8)
72 }
73 if (i === 0) {
74 set(x0 + 1, top, 0xc8c0a8)
75 set(x0 + 2, top, 0xc8c0a8)
76 set(x0, cv.ground, line.color)
77 set(x0 + 1, cv.ground + 2, 0x202024)
78 set(x0 + 2, cv.ground + 2, 0x202024)
79 } else {
80 for (let y = top; y < ceiling; y++) if (y < top + 1 || y > top + 4) set(x0 + 6, y, 0xc8c0a8)
81 }
82 }
83
84 if (len < reach) {
85 const spin = Math.floor(now / 150) % 2
86 set(end, ceiling, 0xf2b33d)
87 set(end, ceiling + 1, spin ? 0x3a3a3a : 0xf2b33d)
88 set(end, ceiling + 2, spin ? 0xf2b33d : 0x3a3a3a)
89 set(end, ceiling + 3, 0xf2b33d)
90 set(end - 1, ceiling + 1, 0xc08a20)
91 set(end - 1, ceiling + 2, 0xc08a20)
92 if (hash(Math.floor(now / 200), i) > 0.5) set(end + 1, ceiling + 1 + Math.floor(hash(now, 3) * 2), 0x9a8a70)
93 }
94
95 if (len < TRAINS_AT) return
96 const trains = 1 + Math.floor(len / 90)
97 const carLen = 7
98 const span = Math.max(1, len - carLen - 1)
99 for (let t = 0; t < trains; t++) {
100 const travel = (hash(t, i, 17) * span * 2 + (now / 1000) * line.speed * (owns(c, 'express') ? 1.6 : 1)) % (span * 2)
101 const isOutbound = travel < span
102 const start = off + Math.round(isOutbound ? travel : span * 2 - travel)
103 for (let k = 0; k < carLen; k++) {
104 const x = start + k
105 const isFront = isOutbound ? k === carLen - 1 : k === 0
106 const isWindow = k % 2 === 1 && !isFront
107 set(x, ceiling + 1, isFront ? 0xfff4b0 : isWindow ? 0xfff0b0 : line.color)
108 set(x, ceiling + 2, k === 3 ? mix(line.color, 0x000000, 0.5) : mix(line.color, 0x000000, 0.25))
109 }
110 }
111 })
112}
113