OmniRealm, a god-simulator sandbox in a side panel: terraform a procedural world, seed races and beasts, watch kingdoms rise and war, then intervene with…

OmniRealm, a god-simulator sandbox in a side panel. A procedural world of oceans, coasts, forests, mountains, tundra, desert, swamps and enchanted groves; humans, elves, orcs and dwarves who found kingdoms, farm, build, advance through the ages, make alliances, go to war and rebel; and you, with the power to reshape the land, seed life and intervene.
/omnirealm
On the desktop the map is pixel art with buttons and dropdowns; in the terminal it is drawn in coloured text with a clickable text toolbar. Click the map to give it the keyboard; Esc hands it back.
Pick a category, then a tool. A line under the toolbar says what the tool does.
| Category | Tools |
|---|---|
| Terrain | Ocean, Shallows, Sand, Grass, Forest, Hills, Mountain, Desert, Tundra, Swamp, Enchanted, Corrupted, Heat, Cool |
| Nature | Rain, Lightning, Fire, Tornado, Earthquake, Volcano |
| Life | Humans, Elves, Orcs, Dwarves, Sheep, Chickens, Wolves, Bears, Dragon, Demon, UFO |
| Destroy | Dynamite, Bomb, Napalm, Atomic, Antimatter |
| Magic | Plague, Zombies, Madness, Healing, Blessing, Curse |
| Inspect | Inspect a creature, Remove life |
Click or drag on the map to use a tool; brushes go from 1 to 6. Dragons, demons and UFOs land where you click. When nothing can be placed (people on water, a full world) the panel says why.
Inspect a creature to see its age, health, damage, armour, speed and traits. Click a trait to remove it, add a random one, follow the creature, or smite it.
| Control | Desktop | Terminal |
|---|---|---|
| Pause / play | ❚❚ Pause | Space |
| Speed 1×, 2×, 4× | ⏩ Speed | 1, 2, 3 |
| Brush size | − Smaller, + Bigger | [ and ] |
| Pan | Right-drag, arrows or WASD | Right-drag, arrows or WASD |
| Zoom | 🔭 Zoom out / 🔎 Zoom in | z |
| Kingdom borders | 🗺️ Borders | b |
| Realms and chronicle | 👑 Realms | r |
| Category, tool | Dropdowns | Tab, q and e |
| Save, load | 💾 Save, 📂 Load | Shift+S, Shift+L |
| New world | 🌍 New world | the New world button |
Save keeps one world, across sessions.
The game runs inside a Client region on the panel's own frame clock. On the desktop it posts each frame to the hooks module, which paints it as SVG (textured terrain, sprites tinted by kingdom, fire, storms and explosions), with the game's input region laid exactly over the picture. The buttons live in a Client of their own, so they keep working while the map redraws.
hooks/register.tsx 344 lines1import type { JsonValue, Register } from 'claude-code'
2
3import type { OmniCmd, OmniFrame } from '../types'
4import { worldSvg } from './art'
5
6const PANE = 'omnirealm'
7const TITLE = 'OmniRealm'
8const SAVE_KEY = 'world'
9
10const frameRef = { plugin: 'omnirealm', key: 'frame' } as const
11const cmdsRef = { plugin: 'omnirealm', key: 'cmds' } as const
12
13/** Lines a row of desktop controls takes with the gap after it: a button is taller than a line of text. */
14const BUTTON_ROW_LH = 2
15/** The line under the toolbar saying what the tool does. */
16const HINT_ROWS = 1
17/** Lines kept below the map for the log or the inspected unit's card. */
18const INFO_ROWS = 4
19/** The column's gaps between its parts, half a line each, and a line to spare. */
20const GAPS_LH = 3
21
22/** Cells a label takes: wide for emoji and other pictographs, nothing for a variation selector. */
23function cells(label: string): number {
24 let n = 0
25 for (const ch of label) {
26 const c = ch.codePointAt(0) ?? 0
27 if (c === 0xfe0f || c === 0x200d) continue
28 n += c >= 0x1f000 || (c >= 0x2600 && c <= 0x27bf) || (c >= 0x2b00 && c <= 0x2bff) ? 2 : 1
29 }
30 return n
31}
32
33/** How many rows a wrapping row of buttons with these labels takes at this width. */
34function buttonRows(labels: string[], cols: number): number {
35 let rows = 1, x = 0
36 for (const label of labels) {
37 const w = cells(label) + 4
38 if (x > 0 && x + w > cols) { rows++; x = 0 }
39 x += w + 1
40 }
41 return rows
42}
43
44// Kept in step with the game's TOOLS in world.tsx.
45const CATS: [string, string][] = [
46 ['terrain', '⛰️ Terrain'], ['nature', '🌦️ Nature'], ['life', '🐑 Life'],
47 ['destroy', '💣 Destroy'], ['magic', '✨ Magic'], ['inspect', '🔍 Inspect'],
48]
49const TOOLS: [string, string, string][] = [
50 ['terrain', 'deep', '🌊 Ocean'], ['terrain', 'shallow', '💧 Shallows'], ['terrain', 'sand', '🏖️ Sand'],
51 ['terrain', 'grass', '🌱 Grass'], ['terrain', 'forest', '🌲 Forest'], ['terrain', 'hills', '🟫 Hills'],
52 ['terrain', 'mountain', '🏔️ Mountain'], ['terrain', 'desert', '🏜️ Desert'], ['terrain', 'tundra', '❄️ Tundra'],
53 ['terrain', 'swamp', '🐸 Swamp'], ['terrain', 'enchanted', '🔮 Enchanted'], ['terrain', 'corrupt', '☠️ Corrupted'],
54 ['terrain', 'heat', '🌡️ Heat'], ['terrain', 'cool', '🧊 Cool'],
55 ['nature', 'rain', '🌧️ Rain'], ['nature', 'lightning', '⚡ Lightning'], ['nature', 'fire', '🔥 Fire'],
56 ['nature', 'tornado', '🌪️ Tornado'], ['nature', 'quake', '〰️ Earthquake'], ['nature', 'volcano', '🌋 Volcano'],
57 ['life', 'human', '🧑 Humans'], ['life', 'elf', '🧝 Elves'], ['life', 'orc', '👹 Orcs'], ['life', 'dwarf', '⛏️ Dwarves'],
58 ['life', 'sheep', '🐑 Sheep'], ['life', 'chicken', '🐔 Chickens'], ['life', 'wolf', '🐺 Wolves'], ['life', 'bear', '🐻 Bears'],
59 ['life', 'dragon', '🐉 Dragon'], ['life', 'demon', '😈 Demon'], ['life', 'ufo', '🛸 UFO'],
60 ['destroy', 'dynamite', '🧨 Dynamite'], ['destroy', 'bomb', '💣 Bomb'], ['destroy', 'napalm', '🔥 Napalm'],
61 ['destroy', 'atomic', '☢️ Atomic'], ['destroy', 'antimatter', '🕳️ Antimatter'],
62 ['magic', 'plague', '🦠 Plague'], ['magic', 'zombie', '🧟 Zombies'], ['magic', 'madness', '🌀 Madness'],
63 ['magic', 'heal', '💚 Healing'], ['magic', 'bless', '🙏 Blessing'], ['magic', 'curse', '💀 Curse'],
64 ['inspect', 'inspect', '🔍 Inspect'], ['inspect', 'erase', '🧹 Remove life'],
65]
66
67/** What each tool does, shown under the toolbar while it is picked. */
68const HINTS: Record<string, string> = {
69 deep: 'Paint deep ocean. Floods houses; creatures that can\'t fly drown.',
70 shallow: 'Paint shallow water. Floods houses.',
71 sand: 'Paint sand beaches. Buildable, poor farmland.',
72 grass: 'Paint grassland: the best farmland, where villages grow fastest.',
73 forest: 'Plant forest: wood for building. Burns easily.',
74 hills: 'Raise hills: stone for research. Dwarves love them.',
75 mountain: 'Raise mountains: a wall only dwarves, bears and demons cross.',
76 desert: 'Paint desert. Little food grows here.',
77 tundra: 'Paint snowy tundra.',
78 swamp: 'Paint swamp. Walkable, not buildable.',
79 enchanted: 'Plant enchanted forest: creatures standing on it heal.',
80 corrupt: 'Corrupt the land: it spreads, and demons crawl out of it.',
81 heat: 'Heat up: melts ice and snow, dries land to desert, rock to lava.',
82 cool: 'Cool down: freezes water, cools lava to rock, puts out fires.',
83 rain: 'Rain: puts out fires, greens scorched land and desert, grows forests.',
84 lightning: 'Lightning: strikes one spot, hurting creatures and setting fires.',
85 fire: 'Start a fire: spreads through forest, grass and houses.',
86 tornado: 'Tornado: wanders for a while, flinging creatures and wrecking houses.',
87 quake: 'Earthquake: raises hills, opens rifts, shakes houses down.',
88 volcano: 'Volcano: raises a mountain that pours lava for a while.',
89 human: 'Place humans: they found or join a kingdom, farm and build.',
90 elf: 'Place elves: long-lived, quick, gather wood without felling trees.',
91 orc: 'Place orcs: tough, regenerate, and make enemies easily.',
92 dwarf: 'Place dwarves: sturdy miners who can cross mountains.',
93 sheep: 'Place sheep: they graze and breed on grass; prey for wolves.',
94 chicken: 'Place chickens: they breed on grass; prey for wolves.',
95 wolf: 'Place wolves: they hunt sheep, chickens and sometimes people.',
96 bear: 'Place bears: strong hunters that attack anyone.',
97 dragon: 'Place a dragon: flies anywhere and breathes fire on villages.',
98 demon: 'Place a demon: sets fires and hunts people.',
99 ufo: 'Place a UFO: it abducts creatures, then leaves for the stars.',
100 dynamite: 'Dynamite: a small blast. Drag to blast a trail.',
101 bomb: 'Bomb: a big blast that scorches the land.',
102 napalm: 'Napalm: sets a wide area on fire.',
103 atomic: 'Atomic bomb: destroys everything far around, leaving a crater.',
104 antimatter: 'Antimatter: erases the land itself, leaving open ocean.',
105 plague: 'Plague: infects creatures; it spreads, and can kill. Immune ones resist.',
106 zombie: 'Zombies: turns people into zombies, or raises one; their bite spreads it.',
107 madness: 'Madness: people attack anyone near them for a while.',
108 heal: 'Healing: restores health and cures plague and madness.',
109 bless: 'Blessing: gives each creature under the brush a good trait.',
110 curse: 'Curse: gives each creature under the brush a bad trait.',
111 inspect: 'Inspect: click a creature to see its stats and traits, and edit them.',
112 erase: 'Remove life: removes every creature under the brush.',
113}
114
115/** The hint line: what the tool does, and how big it lands. */
116function hintFor(tool: string, brush: number): string {
117 const single = ['lightning', 'tornado', 'quake', 'volcano', 'dragon', 'demon', 'ufo', 'bomb', 'napalm', 'atomic', 'antimatter', 'inspect']
118 const how = single.includes(tool) ? 'Click the map.' : `Click or drag on the map · brush ${brush}.`
119 return `${HINTS[tool] ?? ''} ${how} Keys after a click: arrows pan, space pauses.`
120}
121
122/** What the Client was last handed per surface, so a load answer keeps it. */
123const lastProps: Record<string, { [k: string]: JsonValue }> = {}
124/** The commands as last drawn; a press appends to these. */
125let cmds: OmniCmd[] = []
126let lastSeq = 0
127
128type Message = { type: 'frame'; frame?: OmniFrame; save?: JsonValue; load?: boolean }
129
130const isMessage = (data: unknown): data is Message =>
131 typeof data === 'object' && data !== null && (data as Message).type === 'frame'
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 await $.command.register({
136 name: 'omnirealm',
137 description: 'Open OmniRealm, the god-simulator sandbox, in a side panel',
138 })
139
140 return next(e)
141 })
142
143 on('command.run', { command: 'omnirealm' }, async $ => {
144 await $.ui.open({ id: PANE, title: TITLE })
145
146 return { text: 'OmniRealm opened. Click the map to give it the keys; Esc hands them back.' }
147 })
148
149 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
150 if (e.surface === 'terminal') {
151 const { Box, Client } = $.ui.resolve(e)
152 lastProps.terminal = {}
153
154 return (
155 <Box flexDirection="column" width="100%">
156 <Client key="world" module="./world.tsx" width="100%" height={Math.max(16, e.props.scroll.bodyRows)} />
157 </Box>
158 )
159 }
160 if (e.surface !== 'desktop') {
161 const { Text } = $.ui.resolve(e)
162 return <Text dimColor>OmniRealm needs the terminal or the desktop app to draw its world.</Text>
163 }
164
165 // Desktop: the toolbar on top, where nothing below it can push it away;
166 // then the map, a pixel-art picture with the game's input region laid
167 // exactly over it, sized to leave room for everything else; then info.
168 // Every button and dropdown is drawn by a Client of its own (see
169 // controls.tsx): this pane redraws with each frame of the map, and a
170 // Button it drew would lose its press to the next drawing.
171 const { Box, Text, Svg, Client } = $.ui.resolve(e)
172 const { value: frame } = await $.state.get(frameRef)
173 cmds = (await $.state.get(cmdsRef)).value ?? []
174 const hud = frame?.hud
175 const info = frame?.info
176 const cat = hud?.cat ?? 'life'
177 const isRealms = hud?.view === 'realms' && frame !== undefined
178
179 // The header names the tool, brush, speed and state these set.
180 const speedNext = hud?.speed === 1 ? 2 : hud?.speed === 2 ? 4 : 1
181 const toolbarButtons = [
182 { key: 'pause', label: hud?.paused ? '▶ Play' : '❚❚ Pause', act: 'pause', active: hud?.paused === true },
183 { key: 'speed', label: `⏩ Speed ${hud?.speed ?? 1}×`, act: `speed:${speedNext}` },
184 { key: 'brush-down', label: '− Smaller', act: 'brush:-1' },
185 { key: 'brush-up', label: '+ Bigger', act: 'brush:1' },
186 { key: 'zoom', label: hud?.zoom === 1 ? '🔎 Zoom in' : '🔭 Zoom out', act: 'zoom' },
187 { key: 'borders', label: '🗺️ Borders', act: 'borders', active: hud?.borders === true },
188 { key: 'realms', label: '👑 Realms', act: 'realms', active: isRealms },
189 { key: 'save', label: '💾 Save', act: 'save' },
190 { key: 'load', label: '📂 Load', act: 'load' },
191 { key: 'new', label: '🌍 New world', act: 'new' },
192 ]
193 const toolOptions = TOOLS.filter(([c]) => c === cat).map(([, id, label]) => ({ value: id, label }))
194 const toolbarProps = {
195 selects: [
196 { key: 'cat', value: cat, act: 'cat', options: CATS.map(([value, label]) => ({ value, label })) },
197 { key: 'tool', value: hud?.tool ?? '', act: 'tool', options: toolOptions },
198 ],
199 buttons: toolbarButtons,
200 }
201 const catLabel = CATS.find(([id]) => id === cat)?.[1] ?? ''
202 const toolLabel = toolOptions.find(o => o.value === hud?.tool)?.label ?? ''
203
204 // Room the map may take: the pane less the header, the toolbar's rows as
205 // they wrap at this width, the info below and the gaps.
206 const cols = Math.max(20, e.props.bodyColumns)
207 const toolbarRows = buttonRows([`${catLabel} ▾`, `${toolLabel} ▾`, ...toolbarButtons.map(s => s.label)], cols)
208 const realmRows = isRealms ? Math.min(frame.realms.length, 8) + 2 + frame.log.length : 0
209 const chrome = 1 + toolbarRows * BUTTON_ROW_LH + HINT_ROWS + INFO_ROWS + realmRows + GAPS_LH
210 const availRows = Math.max(8, e.props.scroll.bodyRows - chrome)
211 const props = { desktop: true, availRows, cmds }
212 lastProps.desktop = props
213
214
215 const header = hud === undefined
216 ? <Text dimColor>Shaping the world…</Text>
217 : (
218 <Text wrap="truncate-end">
219 <Text bold color="#ffd23f">OmniRealm</Text>
220 <Text dimColor>{` Year ${hud.year} · ${hud.pop} people · ${hud.kingdoms} kingdoms${hud.wars > 0 ? ` · ${hud.wars} at war` : ''} · ${hud.beasts} beasts `}</Text>
221 <Text bold color="#74c0fc">{`${hud.toolLabel}${hud.cat !== 'inspect' && hud.tool !== 'lightning' ? ` · brush ${hud.brush}` : ''}`}</Text>
222 <Text color={hud.paused ? '#ffa94d' : '#8ce99a'}>{hud.paused ? ' ❚❚ paused' : ` ▶ ${hud.speed}×`}</Text>
223 </Text>
224 )
225
226 const toolbar = (
227 <Box flexDirection="column">
228 <Client key="toolbar" module="./controls.tsx" props={toolbarProps} />
229 <Box key="hint"><Text dimColor wrap="truncate-end">{hud === undefined ? ' ' : hintFor(hud.tool, hud.brush)}</Text></Box>
230 </Box>
231 )
232
233 const realms = isRealms
234 ? (
235 <Box flexDirection="column">
236 <Text bold>{`Realms of the world (${frame.realms.length})`}</Text>
237 {frame.realms.length === 0 && <Text dimColor>No kingdoms yet: drop humans, elves, orcs or dwarves on land.</Text>}
238 {frame.realms.slice(0, 8).map(r => (
239 <Text key={`realm-${r.name}`} wrap="truncate-end">
240 <Text color={r.color}>■ </Text>
241 <Text bold color={r.color}>{r.name}</Text>
242 <Text dimColor>{` ${r.text}`}</Text>
243 {r.wars ? <Text color="#ff8787">{` · ⚔ ${r.wars}`}</Text> : null}
244 {r.allies ? <Text color="#8ce99a">{` · ♥ ${r.allies}`}</Text> : null}
245 </Text>
246 ))}
247 <Text bold>Chronicle</Text>
248 {frame.log.map((line, n) => <Text key={`chron-${n}`} dimColor wrap="truncate-end">{line}</Text>)}
249 </Box>
250 )
251 : null
252
253 // The map's box is the pane's width by `availRows` rows: the picture is
254 // stretched over it (a flex row stretches its height; it fills its box),
255 // and the game's region, as wide as the pane, draws itself that many rows
256 // tall over the same box, so a click's cell maps straight to a tile. The
257 // desktop sizes a Client by what it draws, never by a percentage height.
258 const map = (
259 <Box key="map" position="relative" width="100%">
260 <Box flexDirection="row" width="100%" height={availRows}>
261 {frame !== undefined
262 ? <Svg source={worldSvg(frame)} alt={`OmniRealm world map, year ${frame.hud.year}`} />
263 : <Text dimColor> </Text>}
264 </Box>
265 <Box position="absolute" top={0} left={0} width="100%" flexDirection="column">
266 <Client key="world" module="./world.tsx" props={props} />
267 </Box>
268 </Box>
269 )
270
271 const card = info?.kind === 'unit'
272 ? {
273 label: info.traits.length > 0 ? 'Traits (click to remove):' : 'No traits',
274 buttons: [
275 ...info.traits.map(t => ({ key: `trait-${t.name}`, label: `${t.good ? '✦' : '✧'} ${t.name.replace('_', ' ')} ✕`, act: `untrait:${t.name}` })),
276 ...(info.canTrait ? [{ key: 'trait-add', label: '+ Random trait', act: 'trait' }] : []),
277 { key: 'follow', label: info.follow ? '📍 Stop following' : '📍 Follow', act: 'follow', active: info.follow },
278 { key: 'smite', label: '⚡ Smite', act: 'smite' },
279 { key: 'close', label: '✕ Close', act: 'close' },
280 ],
281 }
282 : undefined
283 const details = info?.kind === 'unit' && card !== undefined
284 ? (
285 <Box flexDirection="column" rowGap={1}>
286 <Text wrap="truncate-end">
287 <Text bold color={info.color}>{info.name}</Text>
288 <Text dimColor>{` ${info.line}`}</Text>
289 </Text>
290 <Client key="card" module="./controls.tsx" props={card} />
291 </Box>
292 )
293 : isRealms
294 ? null
295 : (
296 <Box flexDirection="column">
297 {(frame?.log ?? []).map((line, n) => <Text key={`log-${n}`} dimColor={n > 0} wrap="truncate-end">{line}</Text>)}
298 </Box>
299 )
300
301 return (
302 <Box flexDirection="column" width="100%" rowGap={1}>
303 {header}
304 {toolbar}
305 {realms}
306 {map}
307 {details}
308 </Box>
309 )
310 })
311
312 // The game posts each frame and its saves; the controls their commands,
313 // handed on to the game through its props.
314 on('ui.message', async ($, e, next) => {
315 const data = e.data as { type?: string; act?: string } | null
316 if (data?.type === 'cmd' && typeof data.act === 'string') {
317 lastSeq = Math.max(lastSeq + 1, Date.now())
318 cmds = [...cmds, { seq: lastSeq, act: data.act }].slice(-16)
319 await $.state.set(cmdsRef, cmds)
320
321 return {}
322 }
323 if (e.element !== 'world' || !isMessage(e.data)) return next(e)
324 const msg = e.data
325 if (msg.save !== undefined) {
326 await $.store.set(SAVE_KEY, msg.save)
327 $.ui.toast('OmniRealm: world saved')
328 }
329 if (msg.frame !== undefined) await $.state.set(frameRef, msg.frame)
330 if (msg.load === true) {
331 const world = await $.store.get(SAVE_KEY)
332 if (world === undefined) {
333 $.ui.toast('OmniRealm: no saved world yet')
334
335 return {}
336 }
337
338 return { props: { ...(lastProps[e.surface] ?? {}), load: world as JsonValue, nonce: Date.now() } }
339 }
340
341 return {}
342 })
343}
344hooks/art.ts 304 lines1import type { OmniFrame } from '../types'
2
3/*
4 * The desktop's picture of the world: pixel-art SVG built from a frame.
5 * Terrain is one path per kind filled with a textured pattern, so the size
6 * follows the shape of the land, not its area; sprites are symbols drawn
7 * from 8x8 pixel maps, tinted by kingdom through `currentColor`.
8 */
9
10const T = 16
11
12// Kept in step with the game's own tables in world.tsx.
13const KIND_IDS = ['human', 'elf', 'orc', 'dwarf', 'sheep', 'chicken', 'wolf', 'bear', 'dragon', 'demon', 'ufo', 'zombie']
14const FX_IDS = ['rain', 'flash', 'boom', 'tornado', 'volcano', 'heal', 'plague', 'magic', 'nuke']
15const DEEP = 0, SHALLOW = 1, SAND = 2, GRASS = 3, FOREST = 4, HILLS = 5, MOUNTAIN = 6, SNOW = 7,
16 DESERT = 8, SWAMP = 9, ENCHANTED = 10, LAVA = 12
17
18const PALETTE: Record<string, string> = {
19 k: '#1b1b1f', w: '#ffffff', h: '#ead9b0', s: '#f2c08d', b: '#7a4a21', B: '#4e2e12', g: '#3f8f3a',
20 G: '#24602a', l: '#6fc35a', r: '#d63a2a', R: '#8f1f16', o: '#f08a24', y: '#ffd23f', e: '#a3a3a3',
21 E: '#5d5d63', p: '#8b4fd6', P: '#ff5fa8', u: '#3b6fc8', t: '#6ff0d0', n: '#f4f8fb', z: '#8fbf4f',
22 m: '#c9ccd1', M: '#8a8f99', c: 'currentColor',
23}
24
25/** Horizontal runs of one colour become one rect each. */
26function pixels(rows: string[], scale: number, palette: Record<string, string> = PALETTE): string {
27 let out = ''
28 rows.forEach((row, y) => {
29 let x = 0
30 while (x < row.length) {
31 const ch = row[x] as string
32 let end = x + 1
33 while (end < row.length && row[end] === ch) end++
34 const fill = palette[ch]
35 if (fill !== undefined) out += `<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${fill}"/>`
36 x = end
37 }
38 })
39 return out
40}
41
42const SPRITES: Record<string, string[]> = {
43 tree: ['...GG...', '..GgGG..', '.GggglG.', '.GgglgG.', '..GggG..', '...bB...', '...bB...', '........'],
44 pine: ['...G....', '..GgG...', '...G....', '..GgG...', '.GgggG..', '..GGG...', '...b....', '........'],
45 mountain: ['........', '...nn...', '..nnEE..', '..eeEE..', '.eeeEEE.', '.eeeeEEE', 'eeeeeEEE', '........'],
46 hill: ['........', '........', '........', '...ll...', '..lggg..', '.lggggG.', 'lgggggGG', '........'],
47 cactus: ['........', '...g....', '.g.g....', '.ggg.g..', '...ggg..', '...g....', '...g....', '........'],
48 reed: ['........', '.b...b..', '.g.b.g..', '.g.g.g..', '.ggg.g..', '..g.gg..', '........', '........'],
49 crystal: ['........', '...t....', '..tpt...', '..ppp.t.', '..pPp.p.', '...p....', '........', '........'],
50 house: ['...cc...', '..cccc..', '.cccccc.', 'cccccccc', '.hhhhhh.', '.hhBBhh.', '.hhBBhh.', '........'],
51 castle: ['c.c..c.c', 'cccccccc', '.mmmmmm.', '.mMmmMm.', '.mmBBmm.', '.mmBBmm.', '.MmBBmM.', '........'],
52 fire: ['...y....', '..yo.y..', '..oyo...', '.oryo.o.', '.orryoo.', '.rroorr.', '..rrRr..', '........'],
53 human: ['...bb...', '..bssb..', '...ss...', '..cccc..', '.sccccs.', '..cccc..', '..u..u..', '..k..k..'],
54 elf: ['..yyyy..', '.s.ss.s.', '...ss...', '..cccc..', '.sccccs.', '..cccc..', '..g..g..', '..k..k..'],
55 orc: ['........', '..zzzz..', '..zwwz..', '.cccccc.', 'zccccccz', '.cccccc.', '..E..E..', '..k..k..'],
56 dwarf: ['........', '..mmmm..', '..ssss..', '..oooo..', '.cooooc.', '.cccccc.', '..b..b..', '..k..k..'],
57 sheep: ['........', '........', '.wwwww..', 'wwwwwwkk', 'wwwwwwkk', '.wwwww..', '.k.k.k..', '........'],
58 chicken: ['........', '....r...', '...ww...', '..wwwo..', 'wwwww...', '.www....', '..y.....', '........'],
59 wolf: ['........', '......e.', '.....eee', 'eeeeeeek', 'eeeeeee.', '.e.e.e..', '.e.e.e..', '........'],
60 bear: ['.B...B..', '.bbbbb..', '.bkbkbbb', '.bbbbbbb', 'bbbbbbbb', 'bbbbbbbb', '.b.b.b.b', '........'],
61 dragon: ['r.....r.', 'rr...rr.', '.rrrrr..', '..rrrrry', '.rrRrrr.', 'rr.rr.rr', 'r..r...r', '........'],
62 demon: ['r......r', '.r....r.', '..PPPP..', '..PyyP..', '.PPPPPP.', 'P.PPPP.P', '..P..P..', '..k..k..'],
63 ufo: ['........', '...tt...', '..tttt..', '.mmmmmm.', 'mmymymmm', '.mmmmmm.', '..y..y..', '........'],
64 zombie: ['...zz...', '..zkzk..', '...zz...', '.zuuuu..', 'z.uuuuz.', '..uuuu..', '..E..E..', '..k..k..'],
65 crown: ['........', 'y..y..y.', 'yyyyyyy.', '........', '........', '........', '........', '........'],
66}
67
68type Pattern = { base: string; rows?: string[]; pal?: Record<string, string> }
69
70const PATTERNS: Pattern[] = [
71 { base: '#1f4f8f', rows: ['........', '.ww.....', 'w..w....', '........', '........', '.....ww.', '....w..w', '........'], pal: { w: '#2f66aa' } },
72 { base: '#3d86c6', rows: ['........', '..ww....', '.w..w...', '........', '........', '......ww', '.....w..', '........'], pal: { w: '#62a8e0' } },
73 { base: '#e2cf8a', rows: ['........', '.d......', '.....d..', '........', '...d....', '........', '......d.', '..d.....'], pal: { d: '#c9b06a' } },
74 { base: '#5fa548', rows: ['........', '.l......', '.dl..d..', '.....dl.', '........', '..d.....', '..dl..l.', '......d.'], pal: { d: '#4c8f3a', l: '#7dc362' } },
75 { base: '#3e7d35', rows: ['........', '..d.....', '......d.', '........', '.d......', '....d...', '........', '.......d'], pal: { d: '#356d2d' } },
76 { base: '#8f9a55', rows: ['........', '..dd....', '........', '.....dd.', '........', '.dd.....', '........', '....dd..'], pal: { d: '#7a8446' } },
77 { base: '#7b746c', rows: ['........', '.d......', '.....d..', '........', '..d.....', '......d.', '........', '...d....'], pal: { d: '#6a645c' } },
78 { base: '#eef3f6', rows: ['........', '..d.....', '......d.', '........', '....d...', '.d......', '........', '......d.'], pal: { d: '#d5e0e8' } },
79 { base: '#e8b866', rows: ['........', '........', '..dddd..', '.d....d.', '........', '........', 'dd....dd', '..dddd..'], pal: { d: '#d6a352' } },
80 { base: '#4f6440', rows: ['........', '.ww.....', 'wwww....', '.ww.....', '.....d..', '....ww..', '...wwww.', '....ww..'], pal: { w: '#3e5a5c', d: '#62784f' } },
81 { base: '#5d4594', rows: ['........', '.s......', '........', '.....s..', '........', '..s.....', '......s.', '........'], pal: { s: '#d6b8ff' } },
82 { base: '#3f1d36', rows: ['..v.....', '..v.....', '...v....', '...vv...', '.....v..', '.....v..', '....v...', '........'], pal: { v: '#8a2f63' } },
83 { base: '#cf3f16', rows: ['........', '.yy.....', '.yoy....', '..y...d.', '.....ddd', '......d.', '..o.....', '........'], pal: { y: '#ffc23a', o: '#ff8a1c', d: '#9e2a0b' } },
84 { base: '#b9e2f2', rows: ['w.......', '.w......', '..w.....', '..w.....', '...ww...', '.....w..', '......w.', '.......w'], pal: { w: '#e8f7fd' } },
85 { base: '#3d3532', rows: ['........', '.a......', '.....a..', '...a....', '........', '......a.', '..a.....', '........'], pal: { a: '#5b4f49' } },
86]
87
88// Water and lava shimmer: a second pattern offset by a pixel, swapped each frame.
89const ALT: Record<number, number> = { [DEEP]: 15, [SHALLOW]: 16, [LAVA]: 17 }
90
91function patternDefs(): string {
92 const one = (id: string, p: Pattern, shift: number) => {
93 const rows = (p.rows ?? []).map(r => r.slice(shift) + r.slice(0, shift))
94 return `<pattern id="${id}" width="${T}" height="${T}" patternUnits="userSpaceOnUse"><rect width="${T}" height="${T}" fill="${p.base}"/>${pixels(rows, 2, p.pal)}</pattern>`
95 }
96 // Land whose every tile carries a sprite has it baked into its texture,
97 // so a view full of forest costs one path, not a sprite per tile.
98 const withSprites = (id: string, p: Pattern, sprites: string[]) =>
99 `<pattern id="${id}" width="${T * sprites.length}" height="${T}" patternUnits="userSpaceOnUse"><rect width="${T * sprites.length}" height="${T}" fill="${p.base}"/>`
100 + sprites.map((sp, n) => `<g transform="translate(${n * T} 0)">${pixels(p.rows ?? [], 2, p.pal)}${pixels(SPRITES[sp] ?? [], 2)}</g>`).join('') + '</pattern>'
101 const baked: Record<number, string[]> = { [FOREST]: ['tree', 'pine', 'tree'], [MOUNTAIN]: ['mountain'], [HILLS]: ['hill', 'hill', '.'] }
102 let out = PATTERNS.map((p, i) => baked[i] !== undefined ? withSprites(`t${i}`, p, baked[i] ?? []) : one(`t${i}`, p, 0)).join('')
103 for (const [t, alt] of Object.entries(ALT)) out += one(`t${alt}`, PATTERNS[Number(t)] as Pattern, 2)
104 return out
105}
106
107const DEFS = `<defs>${patternDefs()}${Object.entries(SPRITES).map(([id, rows]) => `<symbol id="${id}" viewBox="0 0 8 8">${pixels(rows, 1)}</symbol>`).join('')}`
108 + `<radialGradient id="boom"><stop offset="0" stop-color="#fff6c2"/><stop offset=".45" stop-color="#ffb12a"/><stop offset="1" stop-color="#d9480f" stop-opacity="0"/></radialGradient>`
109 + `<radialGradient id="glow"><stop offset="0" stop-color="#ff6a2a" stop-opacity=".7"/><stop offset="1" stop-color="#ff6a2a" stop-opacity="0"/></radialGradient></defs>`
110
111/** A cheap per-tile hash, for decorations that stay put. */
112const hash = (x: number, y: number) => ((x * 73856093) ^ (y * 19349663)) >>> 0
113
114const use = (id: string, x: number, y: number, size = T, extra = '') =>
115 `<use href="#${id}" x="${x}" y="${y}" width="${size}" height="${size}"${extra}/>`
116
117/** What an Svg's source may hold, less a margin. */
118const SVG_BUDGET = 124_000
119
120/**
121 * The picture of a frame, as much of it as fits an Svg's source: past the
122 * budget it drops the sparse decorations and the shore foam, then the
123 * borders' wash, rather than leave the pane blank.
124 */
125export function worldSvg(f: OmniFrame): string {
126 let svg = ''
127 for (const detail of [2, 1, 0]) {
128 svg = drawWorld(f, detail)
129 if (svg.length <= SVG_BUDGET) return svg
130 }
131 return svg
132}
133
134function drawWorld(f: OmniFrame, detail: number): string {
135 const { vw, vh, camX: worldX, camY: worldY } = f
136 const W = vw * T, H = vh * T
137 const out: string[] = []
138 // The pane stretches the picture over a box of its own: as wide as the
139 // pane, as tall as the rows it gives the map. It fills that box exactly,
140 // so the game's input region laid over the same box lines up tile for tile.
141 out.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="4000" height="${Math.round((4000 * vh) / vw)}" preserveAspectRatio="none" shape-rendering="crispEdges">`)
142 out.push(DEFS)
143
144 // Terrain: one path per kind, a run of equal tiles per subpath.
145 const paths = new Map<number, string>()
146 for (let y = 0; y < vh; y++) {
147 let x = 0
148 while (x < vw) {
149 const t = f.terr.charCodeAt(y * vw + x) - 65
150 let end = x + 1
151 while (end < vw && f.terr.charCodeAt(y * vw + end) - 65 === t) end++
152 paths.set(t, (paths.get(t) ?? '') + `M${x * T} ${y * T}h${(end - x) * T}v${T}h-${(end - x) * T}z`)
153 x = end
154 }
155 }
156 const odd = f.tick % 2 === 1
157 for (const [t, d] of paths) out.push(`<path fill="url(#t${odd && ALT[t] !== undefined ? ALT[t] : t})" d="${d}"/>`)
158
159 // Shore foam where land meets water, on the land's edge.
160 let foam = ''
161 for (let y = 0; y < vh; y++) for (let x = 0; x < vw; x++) {
162 const t = f.terr.charCodeAt(y * vw + x) - 65
163 if (t === DEEP || t === SHALLOW) continue
164 const water = (dx: number, dy: number) => {
165 const nx = x + dx, ny = y + dy
166 if (nx < 0 || ny < 0 || nx >= vw || ny >= vh) return false
167 const n = f.terr.charCodeAt(ny * vw + nx) - 65
168 return n === DEEP || n === SHALLOW
169 }
170 if (water(0, -1)) foam += `M${x * T} ${y * T}h${T}`
171 if (water(0, 1)) foam += `M${x * T} ${y * T + T}h${T}`
172 if (water(-1, 0)) foam += `M${x * T} ${y * T}v${T}`
173 if (water(1, 0)) foam += `M${x * T + T} ${y * T}v${T}`
174 }
175 if (foam && detail >= 2) out.push(`<path d="${foam}" stroke="#e9f6ff" stroke-opacity=".55" stroke-width="2" fill="none"/>`)
176
177 // Kingdom borders: a wash of each realm's colour over its land.
178 if (f.owner.length === vw * vh && detail >= 1) {
179 const wash = new Map<number, string>()
180 for (let y = 0; y < vh; y++) {
181 let x = 0
182 while (x < vw) {
183 const o = f.owner.charCodeAt(y * vw + x) - 48
184 let end = x + 1
185 while (end < vw && f.owner.charCodeAt(y * vw + end) - 48 === o) end++
186 if (o > 0) wash.set(o, (wash.get(o) ?? '') + `M${x * T} ${y * T}h${(end - x) * T}v${T}h-${(end - x) * T}z`)
187 x = end
188 }
189 }
190 for (const [o, d] of wash) out.push(`<path fill="${f.colors[o - 1] ?? '#fff'}" fill-opacity=".28" d="${d}"/>`)
191 }
192
193 // Decorations that belong to the land.
194 const houses = new Set<number>()
195 for (let n = 0; n < f.houses.length; n += 3) houses.add(f.houses[n] ?? -1)
196 for (let y = 0; y < vh && detail >= 2; y++) for (let x = 0; x < vw; x++) {
197 const i = y * vw + x
198 if (houses.has(i)) continue
199 const t = f.terr.charCodeAt(i) - 65
200 const hsh = hash(x + worldX, y + worldY)
201 const px = x * T, py = y * T
202 if (t === DESERT && hsh % 9 === 0) out.push(use('cactus', px, py))
203 else if (t === SWAMP && hsh % 4 === 0) out.push(use('reed', px, py))
204 else if (t === ENCHANTED && hsh % 5 === 0) out.push(use('crystal', px, py))
205 else if (t === SNOW && hsh % 11 === 0) out.push(use('pine', px, py))
206 else if (t === GRASS && hsh % 23 === 0) out.push(use('tree', px, py, 12, ' opacity=".9"'))
207 }
208
209 // Houses and capitals in their kingdom's colour.
210 for (let n = 0; n < f.houses.length; n += 3) {
211 const i = f.houses[n] ?? 0, kd = f.houses[n + 1] ?? 0, cap = f.houses[n + 2] === 1
212 out.push(use(cap ? 'castle' : 'house', (i % vw) * T, Math.floor(i / vw) * T, T, ` color="${f.colors[kd] ?? '#ccc'}"`))
213 }
214
215 // Fire, flickering between two sizes.
216 for (const i of f.fire) {
217 const x = (i % vw) * T, y = Math.floor(i / vw) * T
218 const big = (i + f.tick) % 2 === 0
219 out.push(`<rect x="${x}" y="${y}" width="${T}" height="${T}" fill="url(#glow)"/>`)
220 out.push(use('fire', x + (big ? 0 : 2), y + (big ? 0 : 3), big ? T : 12))
221 }
222
223 // Creatures: a step of bob, the crowd spread a little, rulers crowned.
224 for (let n = 0; n < f.units.length; n += 6) {
225 const x = f.units[n] ?? 0, y = f.units[n + 1] ?? 0, kind = KIND_IDS[f.units[n + 2] ?? 0] ?? 'human'
226 const kd = f.units[n + 3] ?? -1, flags = f.units[n + 4] ?? 0, id = f.units[n + 5] ?? 0
227 const big = kind === 'dragon' || kind === 'ufo' || kind === 'demon'
228 const size = big ? 24 : 13
229 const ox = x * T + (T - size) / 2 + (big ? 0 : ((id % 3) - 1) * 2)
230 const oy = y * T + (T - size) / 2 - ((f.tick + id) % 2) - (big ? 4 : 0)
231 const color = kd >= 0 ? f.colors[kd] ?? '#ddd' : '#c8b9a6'
232 if (flags & 8) out.push(`<rect x="${x * T - 1}" y="${y * T - 1}" width="${T + 2}" height="${T + 2}" fill="none" stroke="#fff" stroke-width="2"/>`)
233 if (big) out.push(`<ellipse cx="${x * T + 8}" cy="${y * T + 15}" rx="9" ry="3" fill="#000" fill-opacity=".3"/>`)
234 const tint = flags & 2 ? ' opacity=".75"' : ''
235 out.push(use(kind, ox, oy, size, ` color="${flags & 4 ? '#ff4dff' : color}"${tint}`))
236 if (flags & 2) out.push(`<circle cx="${x * T + 12}" cy="${y * T + 3}" r="2.5" fill="#a9e34b"/>`)
237 if (flags & 1) out.push(use('crown', ox + 2, oy - 6, 9))
238 }
239
240 // Powers in flight.
241 for (let n = 0; n < f.fx.length; n += 5) {
242 const kind = FX_IDS[f.fx[n] ?? 0], x = f.fx[n + 1] ?? 0, y = f.fx[n + 2] ?? 0, r = f.fx[n + 3] ?? 0, ttl = f.fx[n + 4] ?? 0
243 const cx = x * T + 8, cy = y * T + 8, R = (r + 0.5) * T
244 switch (kind) {
245 case 'rain': {
246 out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#5c7a99" fill-opacity=".22"/>`)
247 let d = ''
248 for (let k = 0; k < r * 6 + 6; k++) {
249 const a = hash(k, f.tick) % 1000 / 1000 * Math.PI * 2, dd = (hash(f.tick, k) % 1000) / 1000 * R
250 const sx = cx + Math.cos(a) * dd, sy = cy + Math.sin(a) * dd
251 d += `M${sx.toFixed(0)} ${sy.toFixed(0)}l-3 8`
252 }
253 out.push(`<path d="${d}" stroke="#cfe8ff" stroke-width="1.5" stroke-opacity=".85"/>`)
254 break
255 }
256 case 'flash':
257 out.push(`<circle cx="${cx}" cy="${cy}" r="${T * 1.5}" fill="#fffbe0" fill-opacity=".55"/>`)
258 out.push(`<path d="M${cx + 2} ${cy - 120}l-10 40h10l-12 44h9l-8 36 26-48h-10l12-40h-10l10-32z" fill="#fff59a" stroke="#fff" stroke-width="1" shape-rendering="auto"/>`)
259 break
260 case 'boom':
261 out.push(`<circle cx="${cx}" cy="${cy}" r="${R * (1.4 - ttl * 0.15)}" fill="url(#boom)" shape-rendering="auto"/>`)
262 break
263 case 'nuke': {
264 const grow = 1.25 - ttl / 12
265 out.push(`<circle cx="${cx}" cy="${cy}" r="${R * grow}" fill="url(#boom)" shape-rendering="auto"/>`)
266 if (ttl > 6) out.push(`<rect width="${W}" height="${H}" fill="#fff" fill-opacity="${((ttl - 6) / 5).toFixed(2)}"/>`)
267 else out.push(`<g shape-rendering="auto" fill="#9c8f86" fill-opacity=".85"><rect x="${cx - 8}" y="${cy - 70}" width="16" height="70"/><ellipse cx="${cx}" cy="${cy - 78}" rx="46" ry="26"/><ellipse cx="${cx}" cy="${cy - 92}" rx="30" ry="16" fill="#b8aaa0"/></g>`)
268 break
269 }
270 case 'tornado': {
271 const s = f.tick % 2 === 0 ? 1 : -1
272 out.push(`<g shape-rendering="auto" fill="none" stroke="#d9dde2" stroke-width="3" stroke-opacity=".9">`
273 + `<ellipse cx="${cx}" cy="${cy - 26}" rx="22" ry="6"/><ellipse cx="${cx + 3 * s}" cy="${cy - 16}" rx="16" ry="5"/>`
274 + `<ellipse cx="${cx - 2 * s}" cy="${cy - 7}" rx="10" ry="4"/><ellipse cx="${cx + s}" cy="${cy + 1}" rx="5" ry="3"/></g>`)
275 break
276 }
277 case 'volcano':
278 out.push(`<circle cx="${cx}" cy="${cy}" r="${T * 3}" fill="url(#glow)" shape-rendering="auto"/>`)
279 out.push(`<g fill="#ffb12a">${[0, 1, 2, 3, 4].map(k => { const a = hash(k, f.tick) % 628 / 100; const d = 8 + (hash(f.tick, k) % 30); return `<rect x="${(cx + Math.cos(a) * d).toFixed(0)}" y="${(cy - 10 - Math.abs(Math.sin(a)) * d).toFixed(0)}" width="3" height="3"/>` }).join('')}</g>`)
280 break
281 case 'heal':
282 out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#8ce99a" fill-opacity=".18"/>`)
283 out.push(`<g fill="#b2f2bb">${[0, 1, 2, 3, 4, 5].map(k => { const a = k + f.tick * 0.3; const d = R * 0.6; const px = cx + Math.cos(a) * d, py = cy + Math.sin(a) * d - ttl; return `<rect x="${(px - 1).toFixed(0)}" y="${(py - 4).toFixed(0)}" width="3" height="9"/><rect x="${(px - 4).toFixed(0)}" y="${(py - 1).toFixed(0)}" width="9" height="3"/>` }).join('')}</g>`)
284 break
285 case 'plague':
286 out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#94d82d" fill-opacity=".28" shape-rendering="auto"/>`)
287 break
288 case 'magic':
289 out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#be4bdb" fill-opacity=".2" shape-rendering="auto"/>`)
290 out.push(`<g fill="#f3d9fa">${[0, 1, 2, 3, 4, 5, 6].map(k => { const a = hash(k, ttl) % 628 / 100; const d = (hash(ttl, k) % 100) / 100 * R; return `<rect x="${(cx + Math.cos(a) * d).toFixed(0)}" y="${(cy + Math.sin(a) * d).toFixed(0)}" width="3" height="3"/>` }).join('')}</g>`)
291 break
292 }
293 }
294
295 // The brush under the pointer.
296 if (f.hover.length === 3) {
297 const [x = 0, y = 0, r = 0] = f.hover
298 out.push(`<circle cx="${x * T + 8}" cy="${y * T + 8}" r="${(r + 0.5) * T}" fill="#fff" fill-opacity=".12" stroke="#fff" stroke-opacity=".8" stroke-width="1.5" stroke-dasharray="4 3" shape-rendering="auto"/>`)
299 }
300
301 out.push('</svg>')
302 return out.join('')
303}
304hooks/world.tsx 1726 lines1import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface, JsonValue } from 'claude-code'
2import type { OmniCmd, OmniFrame, OmniInfo, OmniRealm } from '../types'
3
4/*
5 * OmniRealm: a god-simulator sandbox drawn in a Client region. The whole
6 * world lives here, in the instance's local state, and runs on the region's
7 * frame clock; the hooks module only opens the pane and keeps saves.
8 */
9
10// ---------------------------------------------------------------- world ---
11
12const W = 112
13const H = 72
14const TICK_MS = 120
15const TICKS_PER_YEAR = 48
16const MAX_UNITS = 520
17/** Room above the natural cap kept for what the god places by hand. */
18const GOD_RESERVE = 60
19const HOUSE_HP = 60
20/** What a drawing may serialize to, under the 100,000 a Client's tree is held to. */
21const TREE_BUDGET = 90_000
22
23const DEEP = 0, SHALLOW = 1, SAND = 2, GRASS = 3, FOREST = 4, HILLS = 5, MOUNTAIN = 6, SNOW = 7,
24 DESERT = 8, SWAMP = 9, ENCHANTED = 10, CORRUPTED = 11, LAVA = 12, ICE = 13, SCORCHED = 14
25
26const TERRAIN_NAME = ['Deep ocean', 'Shallows', 'Sand', 'Grassland', 'Forest', 'Hills', 'Mountains', 'Tundra',
27 'Desert', 'Swamp', 'Enchanted forest', 'Corrupted land', 'Lava', 'Ice', 'Scorched earth']
28const TERRAIN_BG = ['#1b3a6b', '#2f6fb3', '#d9c47c', '#5c9e45', '#2e6a2e', '#8c8a55', '#6b6763', '#e4ecf0',
29 '#e0b266', '#4b5a3a', '#5b3f8c', '#3d1f33', '#cf431b', '#a9d9ef', '#3b3431']
30const TERRAIN_GLYPH = [' ', ' ', ' ', ' ', '♣', '^', '▲', ' ', ' ', '~', '*', '%', '~', ' ', '.']
31const TERRAIN_FG = ['', '', '', '', '#4f9c3c', '#b4ae7b', '#a7a29d', '', '', '#708251', '#cda7ff', '#8f2f63', '#ffb320', '', '#6c6058']
32
33const isWater = (t: number) => t === DEEP || t === SHALLOW
34const FLAMMABLE: Record<number, number> = { [FOREST]: 0.22, [GRASS]: 0.07, [ENCHANTED]: 0.05, [SWAMP]: 0.015, [SNOW]: 0.01 }
35const BUILDABLE = new Set([GRASS, SAND, DESERT, SNOW, HILLS, FOREST, ENCHANTED, SCORCHED])
36
37// ---------------------------------------------------------------- kinds ---
38
39type Race = 'human' | 'elf' | 'orc' | 'dwarf'
40type Kind = Race | 'sheep' | 'chicken' | 'wolf' | 'bear' | 'dragon' | 'demon' | 'ufo' | 'zombie'
41
42type KindInfo = { ch: string; color: string; hp: number; dmg: number; arm: number; spd: number; life: number; fly?: boolean; label: string }
43
44const KINDS: Record<Kind, KindInfo> = {
45 human: { ch: 'h', color: '#f4c99b', hp: 30, dmg: 4, arm: 1, spd: 2, life: 70, label: 'Human' },
46 elf: { ch: 'e', color: '#9ff0b4', hp: 25, dmg: 4, arm: 0, spd: 3, life: 220, label: 'Elf' },
47 orc: { ch: 'o', color: '#8fd14f', hp: 40, dmg: 6, arm: 1, spd: 2, life: 55, label: 'Orc' },
48 dwarf: { ch: 'd', color: '#e0a060', hp: 40, dmg: 5, arm: 3, spd: 1, life: 160, label: 'Dwarf' },
49 sheep: { ch: 's', color: '#ffffff', hp: 10, dmg: 0, arm: 0, spd: 1, life: 14, label: 'Sheep' },
50 chicken: { ch: 'c', color: '#ffe680', hp: 5, dmg: 0, arm: 0, spd: 2, life: 8, label: 'Chicken' },
51 wolf: { ch: 'w', color: '#c8c8c8', hp: 22, dmg: 5, arm: 0, spd: 3, life: 15, label: 'Wolf' },
52 bear: { ch: 'B', color: '#a0703a', hp: 50, dmg: 9, arm: 2, spd: 2, life: 28, label: 'Bear' },
53 dragon: { ch: 'D', color: '#ff3b1f', hp: 320, dmg: 26, arm: 6, spd: 2, life: 9999, fly: true, label: 'Dragon' },
54 demon: { ch: 'X', color: '#ff2a7a', hp: 130, dmg: 15, arm: 3, spd: 2, life: 9999, label: 'Demon' },
55 ufo: { ch: 'U', color: '#7dffd8', hp: 160, dmg: 0, arm: 5, spd: 3, life: 9999, fly: true, label: 'UFO' },
56 zombie: { ch: 'z', color: '#7fbf3f', hp: 26, dmg: 5, arm: 0, spd: 1, life: 9999, label: 'Zombie' },
57}
58
59const RACES: Race[] = ['human', 'elf', 'orc', 'dwarf']
60const isRace = (k: Kind): k is Race => k === 'human' || k === 'elf' || k === 'orc' || k === 'dwarf'
61const isPrey = (k: Kind) => k === 'sheep' || k === 'chicken'
62const isMonster = (k: Kind) => k === 'wolf' || k === 'bear' || k === 'dragon' || k === 'demon' || k === 'ufo' || k === 'zombie'
63
64const TRAITS = ['genius', 'immune', 'bloodlust', 'giant', 'fast', 'strong', 'lucky', 'wise', 'fire_proof', 'regenerates',
65 'weak', 'slow', 'frail'] as const
66type Trait = (typeof TRAITS)[number]
67const GOOD_TRAITS: Trait[] = ['genius', 'immune', 'giant', 'fast', 'strong', 'lucky', 'wise', 'fire_proof', 'regenerates']
68const BAD_TRAITS: Trait[] = ['weak', 'slow', 'frail', 'bloodlust']
69
70const SYLLABLES: Record<Race, string[]> = {
71 human: ['al', 'ber', 'cor', 'dan', 'ed', 'fran', 'gar', 'hen', 'is', 'jo', 'ka', 'lo', 'mar', 'ric', 'sten', 'wil', 'ma', 'ra'],
72 elf: ['ae', 'lin', 'thal', 'ri', 'el', 'sil', 'van', 'nor', 'ith', 'ya', 'lor', 'iel', 'ga', 'la', 'dri'],
73 orc: ['gr', 'ug', 'mok', 'thar', 'zug', 'gor', 'krag', 'ush', 'bol', 'rok', 'naz', 'gul'],
74 dwarf: ['dur', 'bal', 'thor', 'grim', 'nar', 'dain', 'kil', 'bor', 'gim', 'rum', 'fun', 'od'],
75}
76const REALM_SUFFIX: Record<Race, string[]> = {
77 human: ['ia', 'land', 'shire', 'mark', 'ford'],
78 elf: ['ndor', 'lith', 'wen', 'rion', 'thas'],
79 orc: ['gash', 'mor', 'uk', 'nakh', 'zor'],
80 dwarf: ['heim', 'grund', 'dal', 'karak', 'hold'],
81}
82const KINGDOM_COLORS = ['#ff5a5f', '#4ea8ff', '#ffd23f', '#c77dff', '#ff9f1c', '#2ec4b6', '#f15bb5', '#a3e635', '#00e5ff', '#ffffff', '#ff7b54', '#9b8cff']
83const AGES = ['Stone Age', 'Bronze Age', 'Iron Age', 'Steel Age', 'Gunpowder Age', 'Arcane Age']
84
85type Unit = {
86 id: number; k: Kind; x: number; y: number
87 hp: number; max: number; dmg: number; arm: number; spd: number; age: number; life: number
88 traits: Trait[]; kd: number; name: string
89 sick: number; mad: number; tgt: number; tx: number; ty: number; mode: number; cd: number
90}
91
92type Kingdom = {
93 id: number; race: Race; name: string; color: string; cx: number; cy: number
94 wood: number; stone: number; food: number; tech: number; research: number
95 king: number; rel: Record<string, number>; war: number[]; weary: Record<string, number>
96 alive: boolean; founded: number; pop: number; houses: number
97}
98
99type Fx = { kind: 'rain' | 'flash' | 'boom' | 'tornado' | 'volcano' | 'heal' | 'plague' | 'magic' | 'nuke'; x: number; y: number; r: number; ttl: number }
100
101type Cat = 'terrain' | 'nature' | 'life' | 'destroy' | 'magic' | 'inspect'
102type View = 'map' | 'realms'
103
104type Region = { row: number; x0: number; x1: number; act: () => void }
105
106type Game = {
107 terrain: Uint8Array; fire: Uint8Array; house: Int16Array; hhp: Uint8Array; territory: Int16Array
108 units: Unit[]; byId: Map<number, Unit>; kingdoms: Kingdom[]; fx: Fx[]; log: string[]
109 nextId: number; tick: number; speed: number; paused: boolean
110 camX: number; camY: number; zoom: 1 | 2; cat: Cat; tool: string; brush: number
111 view: View; borders: boolean; selected: number; follow: boolean
112 hover: { x: number; y: number } | null; painting: boolean; lastPaint: number
113 panning: { x: number; y: number; camX: number; camY: number } | null
114 regions: Region[]; mapTop: number; mapRows: number; drawZoom: 1 | 2; detail: number; frames: number; loadedNonce: number; flash: string; flashTtl: number
115 head: Int32Array; nextIdx: Int32Array
116 /** Desktop: the map is the hooks module's picture, this region only its input. */
117 desktop: boolean; dvh: number; cmdSeq: number; availRows: number; cols: number; rows: number
118 outbox: { save?: boolean; load?: boolean }; lastFrame: string
119}
120
121// ------------------------------------------------------------- helpers ---
122
123const rnd = Math.random
124const ri = (n: number) => Math.floor(rnd() * n)
125const pick = <T,>(xs: readonly T[]): T => xs[ri(xs.length)] as T
126const inBounds = (x: number, y: number) => x >= 0 && y >= 0 && x < W && y < H
127const dist = (ax: number, ay: number, bx: number, by: number) => Math.max(Math.abs(ax - bx), Math.abs(ay - by))
128const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
129const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]] as const
130
131function mix(a: string, b: string, t: number): string {
132 const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16)
133 const ch = (s: number) => Math.round(((pa >> s) & 255) * (1 - t) + ((pb >> s) & 255) * t)
134 return '#' + ((1 << 24) | (ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).slice(1)
135}
136
137function nameFor(race: Race): string {
138 const s = SYLLABLES[race]
139 return cap(pick(s) + pick(s) + (rnd() < 0.3 ? pick(s) : ''))
140}
141
142function log(g: Game, text: string) {
143 g.log.push(`Y${year(g)} ${text}`)
144 if (g.log.length > 80) g.log.shift()
145}
146
147const year = (g: Game) => Math.floor(g.tick / TICKS_PER_YEAR)
148
149// ----------------------------------------------------------- generation ---
150
151function noise(scale: number): (x: number, y: number) => number {
152 const gw = Math.ceil(W / scale) + 2, gh = Math.ceil(H / scale) + 2
153 const lattice = Array.from({ length: gw * gh }, () => rnd())
154 const sm = (t: number) => t * t * (3 - 2 * t)
155 return (x, y) => {
156 const fx = x / scale, fy = y / scale
157 const x0 = Math.floor(fx), y0 = Math.floor(fy)
158 const tx = sm(fx - x0), ty = sm(fy - y0)
159 const v = (i: number, j: number) => lattice[j * gw + i] ?? 0
160 const a = v(x0, y0) * (1 - tx) + v(x0 + 1, y0) * tx
161 const b = v(x0, y0 + 1) * (1 - tx) + v(x0 + 1, y0 + 1) * tx
162 return a * (1 - ty) + b * ty
163 }
164}
165
166function fbm(scales: [number, number][]): (x: number, y: number) => number {
167 const layers = scales.map(([s, w]) => [noise(s), w] as const)
168 return (x, y) => layers.reduce((sum, [n, w]) => sum + n(x, y) * w, 0)
169}
170
171function generate(terrain: Uint8Array) {
172 const elev = fbm([[22, 0.5], [10, 0.3], [4, 0.2]])
173 const moist = fbm([[18, 0.6], [6, 0.4]])
174 const magic = noise(9)
175 for (let y = 0; y < H; y++) {
176 for (let x = 0; x < W; x++) {
177 const nx = x / W - 0.5, ny = y / H - 0.5
178 const edge = Math.sqrt(nx * nx * 1.1 + ny * ny * 1.4) * 0.55
179 const e = elev(x, y) - edge + 0.18
180 const temp = 1 - Math.abs(ny) * 2.1 + (moist(x + 50, y) - 0.5) * 0.3 - Math.max(0, e - 0.6) * 1.2
181 const m = moist(x, y)
182 let t: number
183 if (e < 0.3) t = DEEP
184 else if (e < 0.37) t = temp < 0.12 ? ICE : SHALLOW
185 else if (e < 0.4) t = temp < 0.2 ? SNOW : SAND
186 else if (e < 0.63) {
187 if (temp < 0.22) t = SNOW
188 else if (temp > 0.72 && m < 0.42) t = DESERT
189 else if (m > 0.66) t = temp > 0.55 ? SWAMP : FOREST
190 else if (m > 0.5) t = FOREST
191 else t = GRASS
192 if ((t === FOREST || t === GRASS) && magic(x, y) > 0.8) t = ENCHANTED
193 } else if (e < 0.72) t = HILLS
194 else if (e < 0.82) t = MOUNTAIN
195 else t = SNOW
196 terrain[y * W + x] = t
197 }
198 }
199}
200
201function newGame(): Game {
202 const g: Game = {
203 terrain: new Uint8Array(W * H), fire: new Uint8Array(W * H), house: new Int16Array(W * H),
204 hhp: new Uint8Array(W * H), territory: new Int16Array(W * H),
205 units: [], byId: new Map(), kingdoms: [], fx: [], log: [],
206 nextId: 1, tick: 0, speed: 1, paused: false,
207 camX: 0, camY: 0, zoom: 2, cat: 'life', tool: 'human', brush: 2,
208 view: 'map', borders: false, selected: -1, follow: false,
209 hover: null, painting: false, lastPaint: -1, panning: null,
210 regions: [], mapTop: 1, mapRows: 0, drawZoom: 2, detail: 3, frames: 0,
211 desktop: false, dvh: 0, cmdSeq: 0, availRows: 30, cols: 0, rows: 0, outbox: {}, lastFrame: '', loadedNonce: 0, flash: '', flashTtl: 0,
212 head: new Int32Array(W * H), nextIdx: new Int32Array(MAX_UNITS + 64),
213 }
214 generate(g.terrain)
215 seedLife(g)
216 // Open on the peoples, not an empty corner of ocean.
217 const first = g.units.find(u => isRace(u.k))
218 if (first !== undefined) { g.camX = first.x - 16; g.camY = first.y - 10 }
219 log(g, 'The world is born.')
220 return g
221}
222
223function randomLand(g: Game, ok: (t: number) => boolean = t => BUILDABLE.has(t) && t !== FOREST): [number, number] | null {
224 for (let i = 0; i < 400; i++) {
225 const x = 6 + ri(W - 12), y = 6 + ri(H - 12)
226 if (ok(g.terrain[y * W + x] ?? DEEP)) return [x, y]
227 }
228 return null
229}
230
231function seedLife(g: Game) {
232 for (const race of RACES) {
233 const at = randomLand(g, t => t === GRASS || (race === 'dwarf' && t === HILLS) || (race === 'elf' && t === ENCHANTED))
234 ?? randomLand(g)
235 if (at === null) continue
236 for (let i = 0; i < 6; i++) spawnNear(g, race, at[0], at[1], 2)
237 }
238 for (let i = 0; i < 18; i++) {
239 const at = randomLand(g, t => t === GRASS)
240 if (at !== null) spawnNear(g, rnd() < 0.7 ? 'sheep' : 'chicken', at[0], at[1], 1)
241 }
242 for (let i = 0; i < 4; i++) {
243 const at = randomLand(g, t => t === FOREST || t === SNOW)
244 if (at !== null) spawnNear(g, rnd() < 0.7 ? 'wolf' : 'bear', at[0], at[1], 1)
245 }
246}
247
248// -------------------------------------------------------------- units ---
249
250function applyTraits(u: Unit) {
251 const base = KINDS[u.k]
252 const has = (t: Trait) => u.traits.includes(t)
253 let max = base.hp, dmg = base.dmg, arm = base.arm, spd = base.spd
254 if (has('giant')) { max *= 1.6; dmg += 2 }
255 if (has('strong')) dmg += 3
256 if (has('weak')) dmg = Math.max(1, dmg - 2)
257 if (has('frail')) max *= 0.7
258 if (has('fast')) spd += 1
259 if (has('slow')) spd -= 1
260 const ratio = u.max > 0 ? u.hp / u.max : 1
261 u.max = Math.round(max)
262 u.hp = Math.max(1, Math.round(u.max * ratio))
263 u.dmg = base.dmg === 0 ? 0 : dmg
264 u.arm = arm
265 u.spd = Math.max(1, Math.min(3, spd))
266}
267
268function walkable(g: Game, u: Unit, x: number, y: number): boolean {
269 if (!inBounds(x, y)) return false
270 if (KINDS[u.k].fly) return true
271 const t = g.terrain[y * W + x] ?? DEEP
272 if (isWater(t)) return false
273 if (t === LAVA) return u.k === 'demon'
274 if (t === MOUNTAIN) return u.k === 'dwarf' || u.k === 'demon' || u.k === 'bear'
275 return true
276}
277
278function spawn(g: Game, k: Kind, x: number, y: number, parent?: Unit, limit = MAX_UNITS): Unit | null {
279 if (g.units.length >= limit || !inBounds(x, y)) return null
280 const info = KINDS[k]
281 const u: Unit = {
282 id: g.nextId++, k, x, y, hp: info.hp, max: info.hp, dmg: info.dmg, arm: info.arm, spd: info.spd,
283 age: parent ? 0 : isRace(k) ? 16 + ri(14) : ri(3), life: Math.round(info.life * (0.8 + rnd() * 0.4)),
284 traits: [], kd: parent?.kd ?? -1, name: isRace(k) ? nameFor(k) : info.label,
285 sick: 0, mad: 0, tgt: -1, tx: -1, ty: -1, mode: 0, cd: ri(3),
286 }
287 if (isRace(k)) {
288 if (k === 'orc') u.traits.push('regenerates')
289 for (const t of parent?.traits ?? []) if (rnd() < 0.5 && !u.traits.includes(t)) u.traits.push(t)
290 if (rnd() < 0.35) {
291 const t = rnd() < 0.75 ? pick(GOOD_TRAITS) : pick(BAD_TRAITS)
292 if (!u.traits.includes(t)) u.traits.push(t)
293 }
294 }
295 if (k === 'demon') u.traits.push('fire_proof')
296 if (u.traits.includes('wise')) u.life = Math.round(u.life * 1.5)
297 applyTraits(u)
298 u.hp = u.max
299 if (!walkable(g, u, x, y)) return null
300 g.units.push(u)
301 g.byId.set(u.id, u)
302 return u
303}
304
305function spawnNear(g: Game, k: Kind, x: number, y: number, r: number, limit = MAX_UNITS): Unit | null {
306 for (let i = 0; i < 12; i++) {
307 const u = spawn(g, k, x + ri(r * 2 + 1) - r, y + ri(r * 2 + 1) - r, undefined, limit)
308 if (u !== null) return u
309 }
310 return null
311}
312
313function rebuildIndex(g: Game) {
314 g.head.fill(-1)
315 if (g.nextIdx.length < g.units.length) g.nextIdx = new Int32Array(g.units.length + 64)
316 g.byId.clear()
317 g.units.forEach((u, i) => {
318 const c = u.y * W + u.x
319 g.nextIdx[i] = g.head[c] ?? -1
320 g.head[c] = i
321 g.byId.set(u.id, u)
322 })
323}
324
325function nearest(g: Game, u: Unit, r: number, ok: (v: Unit) => boolean): Unit | null {
326 let best: Unit | null = null, bd = 1e9
327 for (let y = Math.max(0, u.y - r); y <= Math.min(H - 1, u.y + r); y++) {
328 for (let x = Math.max(0, u.x - r); x <= Math.min(W - 1, u.x + r); x++) {
329 for (let i = g.head[y * W + x] ?? -1; i >= 0; i = g.nextIdx[i] ?? -1) {
330 const v = g.units[i]
331 if (v === undefined || v === u || v.hp <= 0 || !ok(v)) continue
332 const d = dist(u.x, u.y, v.x, v.y)
333 if (d < bd) { bd = d; best = v }
334 }
335 }
336 }
337 return best
338}
339
340function unitsIn(g: Game, x: number, y: number, r: number): Unit[] {
341 return g.units.filter(u => u.hp > 0 && (u.x - x) ** 2 + (u.y - y) ** 2 <= r * r + r)
342}
343
344function stepToward(g: Game, u: Unit, tx: number, ty: number, away = false) {
345 let dx = Math.sign(tx - u.x), dy = Math.sign(ty - u.y)
346 if (away) { dx = -dx; dy = -dy }
347 const tries: (readonly [number, number])[] = [[dx, dy], [dx, 0], [0, dy]]
348 for (let i = 0; i < 3; i++) tries.push(pick(DIRS))
349 for (const [ax, ay] of tries) {
350 if ((ax !== 0 || ay !== 0) && walkable(g, u, u.x + ax, u.y + ay)) { u.x += ax; u.y += ay; return }
351 }
352}
353
354function wander(g: Game, u: Unit) {
355 if (rnd() < 0.6) {
356 const [dx, dy] = pick(DIRS)
357 if (walkable(g, u, u.x + dx, u.y + dy)) { u.x += dx; u.y += dy }
358 }
359}
360
361function hurt(g: Game, v: Unit, amount: number, by?: Unit) {
362 if (v.traits.includes('lucky') && rnd() < 0.25) return
363 v.hp -= Math.max(1, amount - v.arm)
364 if (v.hp <= 0 && by?.k === 'zombie' && isRace(v.k) && !v.traits.includes('immune')) {
365 // Bitten to death: rises again as one of them.
366 zombify(v)
367 }
368}
369
370function zombify(v: Unit) {
371 v.k = 'zombie'; v.kd = -1; v.traits = v.traits.filter(t => t !== 'regenerates'); v.mad = 0; v.sick = 0
372 v.max = 0; applyTraits(v); v.hp = v.max
373}
374
375function strikeOrApproach(g: Game, u: Unit, v: Unit) {
376 if (dist(u.x, u.y, v.x, v.y) <= 1) {
377 const k = u.kd >= 0 ? g.kingdoms[u.kd] : undefined
378 hurt(g, v, u.dmg + (k?.tech ?? 0), u)
379 } else stepToward(g, u, v.x, v.y)
380}
381
382const atWar = (g: Game, a: number, b: number) => a >= 0 && b >= 0 && a !== b && (g.kingdoms[a]?.war.includes(b) ?? false)
383
384function hostile(g: Game, u: Unit, v: Unit): boolean {
385 if (u.mad > 0) return true
386 if (isMonster(v.k) && v.k !== 'ufo') return true
387 if (isRace(v.k) && v.mad > 0) return true
388 return isRace(v.k) && atWar(g, u.kd, v.kd)
389}
390
391function canAct(g: Game, u: Unit): boolean {
392 if (u.cd > 0) { u.cd--; return false }
393 u.cd = 3 - u.spd
394 return true
395}
396
397function act(g: Game, u: Unit) {
398 const i = u.y * W + u.x
399 const t = g.terrain[i] ?? DEEP
400 const fly = KINDS[u.k].fly === true
401 const fireProof = u.traits.includes('fire_proof')
402 if (!fly && (isWater(t) || (t === LAVA && u.k !== 'demon'))) {
403 u.hp -= t === LAVA ? 12 : 3
404 for (const [dx, dy] of DIRS) if (walkable(g, u, u.x + dx, u.y + dy)) { u.x += dx; u.y += dy; break }
405 }
406 if ((g.fire[i] ?? 0) > 0 && !fireProof) u.hp -= 3
407 if (u.traits.includes('regenerates') && u.hp < u.max) u.hp = Math.min(u.max, u.hp + 0.12)
408 if (t === ENCHANTED && u.hp < u.max) u.hp = Math.min(u.max, u.hp + 0.2)
409 if (u.mad > 0) u.mad--
410 if (u.sick > 0) {
411 u.sick++
412 u.hp -= 0.18
413 if (rnd() < 0.08) {
414 const v = nearest(g, u, 1, w => w.sick === 0 && (isRace(w.k) || isPrey(w.k)) && !w.traits.includes('immune'))
415 if (v !== null) v.sick = 1
416 }
417 if (u.sick > 160 && rnd() < 0.02) u.sick = 0
418 }
419 if (!canAct(g, u)) return
420
421 switch (u.k) {
422 case 'human': case 'elf': case 'orc': case 'dwarf': return civAct(g, u)
423 case 'sheep': case 'chicken': {
424 const threat = nearest(g, u, 3, v => v.k === 'wolf' || v.k === 'bear' || v.k === 'zombie')
425 if (threat !== null) return stepToward(g, u, threat.x, threat.y, true)
426 return wander(g, u)
427 }
428 case 'wolf': case 'bear': {
429 if (u.tgt < 0 || (g.tick + u.id) % 5 === 0) {
430 const prey = nearest(g, u, 7, v => isPrey(v.k) || (isRace(v.k) && (u.k === 'bear' || rnd() < 0.3)))
431 u.tgt = prey?.id ?? -1
432 }
433 const prey = g.byId.get(u.tgt)
434 if (prey !== undefined && prey.hp > 0) return strikeOrApproach(g, u, prey)
435 u.tgt = -1
436 return wander(g, u)
437 }
438 case 'zombie': {
439 if (u.tgt < 0 || (g.tick + u.id) % 4 === 0) u.tgt = nearest(g, u, 8, v => v.k !== 'zombie' && !isMonster(v.k))?.id ?? -1
440 const v = g.byId.get(u.tgt)
441 if (v !== undefined && v.hp > 0) return strikeOrApproach(g, u, v)
442 return wander(g, u)
443 }
444 case 'dragon': {
445 if ((g.tick + u.id) % 26 === 0) {
446 const v = nearest(g, u, 6, w => isRace(w.k) || isPrey(w.k))
447 if (v !== null) breathe(g, v.x, v.y)
448 }
449 const v = nearest(g, u, 2, w => isRace(w.k))
450 if (v !== null && rnd() < 0.5) return strikeOrApproach(g, u, v)
451 if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) < 2) { u.tx = ri(W); u.ty = ri(H) }
452 stepToward(g, u, u.tx, u.ty)
453 return
454 }
455 case 'demon': {
456 if (rnd() < 0.05) ignite(g, u.x + ri(3) - 1, u.y + ri(3) - 1)
457 if (u.tgt < 0 || (g.tick + u.id) % 6 === 0) u.tgt = nearest(g, u, 7, v => isRace(v.k))?.id ?? -1
458 const v = g.byId.get(u.tgt)
459 if (v !== undefined && v.hp > 0) return strikeOrApproach(g, u, v)
460 return wander(g, u)
461 }
462 case 'ufo': {
463 u.age += 1 / TICKS_PER_YEAR
464 if (u.age > 14) { u.hp = 0; log(g, 'The UFO leaves for the stars.'); return }
465 if ((g.tick + u.id) % 30 === 0) {
466 const v = nearest(g, u, 2, w => w.k !== 'ufo' && w.k !== 'dragon')
467 if (v !== null) {
468 v.hp = -999
469 g.fx.push({ kind: 'magic', x: v.x, y: v.y, r: 0, ttl: 4 })
470 if (isRace(v.k)) log(g, `${v.name} the ${KINDS[v.k].label} was abducted by a UFO!`)
471 }
472 }
473 if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) < 2) { u.tx = ri(W); u.ty = ri(H) }
474 stepToward(g, u, u.tx, u.ty)
475 return
476 }
477 }
478}
479
480function civAct(g: Game, u: Unit) {
481 if (u.mad > 0) {
482 const v = nearest(g, u, 6, () => true)
483 return v !== null ? strikeOrApproach(g, u, v) : wander(g, u)
484 }
485 if (u.kd < 0 || !(g.kingdoms[u.kd]?.alive ?? false)) {
486 u.kd = -1
487 joinOrFound(g, u)
488 if (u.kd < 0) return wander(g, u)
489 }
490 const k = g.kingdoms[u.kd] as Kingdom
491 const warrior = u.traits.includes('bloodlust') || u.id % (u.k === 'orc' ? 2 : 3) === 0
492 if (u.tgt < 0 || (g.tick + u.id) % 4 === 0) {
493 u.tgt = nearest(g, u, warrior ? 9 : 4, v => hostile(g, u, v))?.id ?? -1
494 }
495 const enemy = g.byId.get(u.tgt)
496 if (enemy !== undefined && enemy.hp > 0) {
497 if (warrior || dist(u.x, u.y, enemy.x, enemy.y) <= 1 || enemy.k === 'sheep') return strikeOrApproach(g, u, enemy)
498 return stepToward(g, u, enemy.x, enemy.y, true)
499 }
500 u.tgt = -1
501 if (warrior && k.war.length > 0) {
502 if (u.mode !== 1 || (g.tick + u.id) % 8 === 0) {
503 const h = nearestHouse(g, u.x, u.y, 14, kd => atWar(g, u.kd, kd))
504 if (h !== null) { u.mode = 1; u.tx = h[0]; u.ty = h[1] } else if (u.mode === 1) { u.mode = 0; u.tx = -1 }
505 }
506 if (u.mode === 1) {
507 const hi = u.ty * W + u.tx
508 if ((g.house[hi] ?? 0) === 0) { u.mode = 0; u.tx = -1; return }
509 if (dist(u.x, u.y, u.tx, u.ty) <= 1) {
510 damageHouse(g, hi, u.dmg + k.tech)
511 if (rnd() < 0.04) ignite(g, u.tx, u.ty)
512 } else stepToward(g, u, u.tx, u.ty)
513 return
514 }
515 }
516 u.mode = 0
517 if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) === 0) {
518 if (u.tx >= 0) work(g, u, k)
519 const home = randomHouse(g, k)
520 const tx = home[0] + ri(13) - 6, ty = home[1] + ri(13) - 6
521 if (walkable(g, u, tx, ty)) { u.tx = tx; u.ty = ty } else u.tx = -1
522 return
523 }
524 stepToward(g, u, u.tx, u.ty)
525 if (rnd() < 0.05) u.tx = -1
526}
527
528function work(g: Game, u: Unit, k: Kingdom) {
529 const i = u.y * W + u.x
530 const t = g.terrain[i] ?? GRASS
531 if (t === FOREST || t === ENCHANTED) {
532 k.wood += u.k === 'elf' ? 2 : 1
533 if (t === FOREST && u.k !== 'elf' && rnd() < 0.08) g.terrain[i] = GRASS
534 } else if (t === HILLS || t === MOUNTAIN) {
535 k.stone += u.k === 'dwarf' ? 3 : 1
536 } else if (t === GRASS || t === SWAMP) {
537 k.food += 2
538 } else if (t === SAND || t === DESERT || t === SNOW || t === SCORCHED) {
539 if (rnd() < 0.5) k.food += 1
540 }
541 if (u.traits.includes('genius')) k.research += 1
542}
543
544function joinOrFound(g: Game, u: Unit) {
545 const race = u.k as Race
546 let best: Kingdom | null = null, bd = 24
547 for (const k of g.kingdoms) {
548 if (!k.alive || k.race !== race) continue
549 const d = dist(u.x, u.y, k.cx, k.cy)
550 if (d < bd) { bd = d; best = k }
551 }
552 if (best !== null) { u.kd = best.id; return }
553 const i = u.y * W + u.x
554 const t = g.terrain[i] ?? DEEP
555 if (!BUILDABLE.has(t) || (g.house[i] ?? 0) !== 0) return
556 const k = foundKingdom(g, race, u.x, u.y)
557 u.kd = k.id
558 k.king = u.id
559 placeHouse(g, k, u.x, u.y)
560 log(g, `${u.name} founds the ${KINDS[race].label.toLowerCase()} kingdom of ${k.name}.`)
561}
562
563function foundKingdom(g: Game, race: Race, x: number, y: number): Kingdom {
564 const used = new Set(g.kingdoms.filter(k => k.alive).map(k => k.color))
565 const color = KINGDOM_COLORS.find(c => !used.has(c)) ?? pick(KINGDOM_COLORS)
566 const k: Kingdom = {
567 id: g.kingdoms.length, race, name: nameFor(race).slice(0, 6) + pick(REALM_SUFFIX[race]), color, cx: x, cy: y,
568 wood: 6, stone: 0, food: 8, tech: 0, research: 0, king: -1, rel: {}, war: [], weary: {},
569 alive: true, founded: g.tick, pop: 1, houses: 0,
570 }
571 for (const o of g.kingdoms) {
572 if (!o.alive) continue
573 const r = affinity(race, o.race) + ri(21) - 10
574 k.rel[o.id] = r
575 o.rel[k.id] = r
576 }
577 g.kingdoms.push(k)
578 return k
579}
580
581function affinity(a: Race, b: Race): number {
582 if (a === b) return 30
583 const pair = [a, b].sort().join('-')
584 const table: Record<string, number> = {
585 'dwarf-human': 15, 'elf-human': 10, 'dwarf-elf': -15, 'elf-orc': -55, 'human-orc': -35, 'dwarf-orc': -40,
586 }
587 return table[pair] ?? 0
588}
589
590function placeHouse(g: Game, k: Kingdom, x: number, y: number) {
591 const i = y * W + x
592 if (g.terrain[i] === FOREST) g.terrain[i] = GRASS
593 g.house[i] = k.id + 1
594 g.hhp[i] = HOUSE_HP
595 g.fire[i] = 0
596}
597
598function damageHouse(g: Game, i: number, amount: number) {
599 const hp = (g.hhp[i] ?? 0) - amount
600 if (hp <= 0) { g.house[i] = 0; g.hhp[i] = 0 } else g.hhp[i] = hp
601}
602
603function nearestHouse(g: Game, x: number, y: number, r: number, ok: (kd: number) => boolean): [number, number] | null {
604 let best: [number, number] | null = null, bd = 1e9
605 for (let yy = Math.max(0, y - r); yy <= Math.min(H - 1, y + r); yy++) {
606 for (let xx = Math.max(0, x - r); xx <= Math.min(W - 1, x + r); xx++) {
607 const h = g.house[yy * W + xx] ?? 0
608 if (h === 0 || !ok(h - 1)) continue
609 const d = dist(x, y, xx, yy)
610 if (d < bd) { bd = d; best = [xx, yy] }
611 }
612 }
613 return best
614}
615
616function randomHouse(g: Game, k: Kingdom): [number, number] {
617 for (let n = 0; n < 6; n++) {
618 const x = k.cx + ri(17) - 8, y = k.cy + ri(17) - 8
619 if (inBounds(x, y) && g.house[y * W + x] === k.id + 1) return [x, y]
620 }
621 return [k.cx, k.cy]
622}
623
624// ---------------------------------------------------------- kingdoms ---
625
626function kingdomsQuarter(g: Game) {
627 const houses: [number, number][][] = g.kingdoms.map(() => [])
628 g.territory.fill(0)
629 for (let i = 0; i < W * H; i++) {
630 const h = g.house[i] ?? 0
631 if (h === 0) continue
632 houses[h - 1]?.push([i % W, Math.floor(i / W)])
633 }
634 for (const k of g.kingdoms) k.pop = 0
635 for (const u of g.units) if (u.kd >= 0 && isRace(u.k)) { const k = g.kingdoms[u.kd]; if (k) k.pop++ }
636
637 for (const k of g.kingdoms) {
638 if (!k.alive) continue
639 const own = houses[k.id] ?? []
640 k.houses = own.length
641 for (const [x, y] of own) {
642 for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
643 if (inBounds(x + dx, y + dy)) g.territory[(y + dy) * W + x + dx] = k.id + 1
644 }
645 }
646 if (k.pop === 0 && k.houses === 0) {
647 k.alive = false
648 log(g, `The kingdom of ${k.name} has fallen.`)
649 for (const o of g.kingdoms) o.war = o.war.filter(id => id !== k.id)
650 continue
651 }
652 if (k.pop === 0) {
653 // Empty houses crumble.
654 for (const [x, y] of own) if (rnd() < 0.2) damageHouse(g, y * W + x, 30)
655 continue
656 }
657 if (g.house[k.cy * W + k.cx] !== k.id + 1 && own.length > 0) {
658 const [x, y] = pick(own)
659 k.cx = x; k.cy = y
660 }
661 // Build.
662 if (k.wood >= 4 && k.houses * 3 < k.pop + 3) {
663 for (let n = 0; n < 12; n++) {
664 const [bx, by] = own.length > 0 ? pick(own) : [k.cx, k.cy]
665 const x = bx + ri(7) - 3, y = by + ri(7) - 3
666 const i = y * W + x
667 const t = g.terrain[i] ?? DEEP
668 if (!inBounds(x, y) || (g.house[i] ?? 0) !== 0 || (g.fire[i] ?? 0) > 0) continue
669 if (!(BUILDABLE.has(t) || (k.race === 'dwarf' && t === MOUNTAIN))) continue
670 if (own.length === 0) { k.cx = x; k.cy = y }
671 placeHouse(g, k, x, y)
672 k.wood -= 4
673 k.houses++
674 break
675 }
676 }
677 // Births.
678 const members = g.units.filter(u => u.kd === k.id && isRace(u.k))
679 if (k.food >= 6 && k.pop < Math.max(2, k.houses * 4) && own.length > 0) {
680 const parent = pick(members)
681 const [x, y] = pick(own)
682 const child = spawnNear(g, k.race, x, y, 1)
683 if (child !== null) {
684 child.kd = k.id
685 child.age = 0
686 for (const t of parent?.traits ?? []) if (rnd() < 0.5 && !child.traits.includes(t)) child.traits.push(t)
687 applyTraits(child)
688 k.food -= 6
689 }
690 }
691 // Research.
692 if (k.stone >= 10) { k.stone -= 10; k.research += 4 }
693 k.research += 1
694 if (k.research >= 60 * (k.tech + 1) && k.tech < AGES.length - 1) {
695 k.research = 0
696 k.tech++
697 log(g, `${k.name} enters the ${AGES[k.tech]}.`)
698 }
699 // A crown for the strongest when the throne is empty.
700 const king = g.byId.get(k.king)
701 if (king === undefined || king.hp <= 0 || king.kd !== k.id) {
702 const heir = members.filter(u => u.age >= 16).sort((a, b) => b.max + b.dmg * 5 - (a.max + a.dmg * 5))[0]
703 if (heir !== undefined) {
704 k.king = heir.id
705 log(g, `${heir.name} is crowned ruler of ${k.name}.`)
706 } else k.king = -1
707 }
708 }
709}
710
711function kingdomsYear(g: Game) {
712 const alive = g.kingdoms.filter(k => k.alive)
713 for (const a of alive) {
714 for (const b of alive) {
715 if (b.id <= a.id) continue
716 let r = a.rel[b.id] ?? affinity(a.race, b.race)
717 r += (affinity(a.race, b.race) - r) * 0.05 + ri(13) - 6
718 if (dist(a.cx, a.cy, b.cx, b.cy) < 28) r -= 4
719 if (atWar(g, a.id, b.id)) {
720 const w = (a.weary[b.id] ?? 0) + 1
721 a.weary[b.id] = w
722 if (w >= 4 && rnd() < 0.3) {
723 makePeace(g, a, b)
724 r = Math.max(r, -10) + 25
725 }
726 } else if (r < -40 && rnd() < 0.35 && year(g) - Math.floor(Math.max(a.founded, b.founded) / TICKS_PER_YEAR) > 2) {
727 declareWar(g, a, b)
728 }
729 r = Math.max(-100, Math.min(100, r))
730 a.rel[b.id] = r
731 b.rel[a.id] = r
732 }
733 }
734 // Rebellion: a sprawling realm, or one without a ruler, may split.
735 for (const k of alive) {
736 const kingless = !g.byId.has(k.king)
737 if (!(k.houses >= 16 && rnd() < 0.12) && !(kingless && k.houses >= 8 && rnd() < 0.25)) continue
738 let far: [number, number] | null = null, fd = 10
739 for (let i = 0; i < W * H; i++) {
740 if (g.house[i] !== k.id + 1) continue
741 const x = i % W, y = Math.floor(i / W), d = dist(x, y, k.cx, k.cy)
742 if (d > fd) { fd = d; far = [x, y] }
743 }
744 if (far === null) continue
745 const rebel = foundKingdom(g, k.race, far[0], far[1])
746 for (let i = 0; i < W * H; i++) {
747 if (g.house[i] === k.id + 1 && dist(i % W, Math.floor(i / W), far[0], far[1]) <= 7) g.house[i] = rebel.id + 1
748 }
749 for (const u of g.units) if (u.kd === k.id && dist(u.x, u.y, far[0], far[1]) <= 8 && u.id !== k.king) u.kd = rebel.id
750 rebel.wood = Math.floor(k.wood / 3)
751 rebel.tech = k.tech
752 rebel.rel[k.id] = -100
753 k.rel[rebel.id] = -100
754 log(g, `Rebellion! ${rebel.name} breaks away from ${k.name}.`)
755 declareWar(g, rebel, k, true)
756 }
757 // Ageing.
758 for (const u of g.units) {
759 if (u.k === 'ufo') continue
760 u.age++
761 if (u.age > u.life) {
762 u.hp = 0
763 if (isRace(u.k) && g.kingdoms[u.kd]?.king === u.id) log(g, `${u.name} of ${g.kingdoms[u.kd]?.name} dies of old age.`)
764 }
765 }
766 // Wildlife.
767 const count = (k: Kind) => g.units.filter(u => u.k === k).length
768 const sheep = count('sheep') + count('chicken')
769 for (const u of [...g.units]) {
770 if (isPrey(u.k) && sheep < 90 && rnd() < 0.35 && g.terrain[u.y * W + u.x] === GRASS) spawnNear(g, u.k, u.x, u.y, 1)
771 else if (u.k === 'wolf' && count('wolf') < 24 && sheep > 10 && rnd() < 0.15) spawnNear(g, 'wolf', u.x, u.y, 1)
772 else if (u.k === 'bear' && count('bear') < 10 && rnd() < 0.05) spawnNear(g, 'bear', u.x, u.y, 1)
773 }
774}
775
776function declareWar(g: Game, a: Kingdom, b: Kingdom, quiet = false) {
777 if (a.war.includes(b.id)) return
778 a.war.push(b.id); b.war.push(a.id)
779 a.weary[b.id] = 0; b.weary[a.id] = 0
780 if (!quiet) log(g, `War! ${a.name} declares war on ${b.name}.`)
781 for (const c of g.kingdoms) {
782 if (!c.alive || c.id === a.id || c.id === b.id || c.war.includes(a.id)) continue
783 if ((c.rel[b.id] ?? 0) > 55) {
784 c.war.push(a.id); a.war.push(c.id)
785 log(g, `${c.name} joins the war at the side of its ally ${b.name}.`)
786 }
787 }
788}
789
790function makePeace(g: Game, a: Kingdom, b: Kingdom) {
791 a.war = a.war.filter(id => id !== b.id)
792 b.war = b.war.filter(id => id !== a.id)
793 log(g, `${a.name} and ${b.name} make peace.`)
794}
795
796// -------------------------------------------------------------- tiles ---
797
798function ignite(g: Game, x: number, y: number) {
799 if (!inBounds(x, y)) return
800 const i = y * W + x
801 const t = g.terrain[i] ?? DEEP
802 if (isWater(t) || t === ICE) return
803 g.fire[i] = 10 + ri(10)
804}
805
806function breathe(g: Game, x: number, y: number) {
807 for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (rnd() < 0.8) ignite(g, x + dx, y + dy)
808 for (const v of unitsIn(g, x, y, 1)) if (v.k !== 'dragon' && !v.traits.includes('fire_proof')) hurt(g, v, 18)
809 g.fx.push({ kind: 'boom', x, y, r: 1, ttl: 3 })
810}
811
812function tiles(g: Game) {
813 // Fire burns down, spreads, and leaves scorched earth.
814 for (let i = 0; i < W * H; i++) {
815 const f = g.fire[i] ?? 0
816 if (f === 0) continue
817 const x = i % W, y = Math.floor(i / W)
818 const t = g.terrain[i] ?? DEEP
819 if (isWater(t) || t === ICE) { g.fire[i] = 0; continue }
820 if ((g.house[i] ?? 0) !== 0) damageHouse(g, i, 6)
821 if (rnd() < 0.5) {
822 const [dx, dy] = pick(DIRS)
823 const j = (y + dy) * W + x + dx
824 if (inBounds(x + dx, y + dy) && (g.fire[j] ?? 0) === 0) {
825 const nt = g.terrain[j] ?? DEEP
826 const chance = (g.house[j] ?? 0) !== 0 ? 0.3 : FLAMMABLE[nt] ?? 0
827 if (rnd() < chance * 3) ignite(g, x + dx, y + dy)
828 }
829 }
830 g.fire[i] = f - 1
831 if (f === 1 && (t === FOREST || t === GRASS || t === ENCHANTED)) g.terrain[i] = SCORCHED
832 }
833 // A few random tiles change on their own: regrowth, lava, corruption.
834 for (let n = 0; n < 500; n++) {
835 const x = ri(W), y = ri(H), i = y * W + x
836 const t = g.terrain[i] ?? DEEP
837 const [dx, dy] = pick(DIRS)
838 const j = (y + dy) * W + x + dx
839 const nt = inBounds(x + dx, y + dy) ? g.terrain[j] ?? DEEP : DEEP
840 if (t === SCORCHED && (g.fire[i] ?? 0) === 0 && rnd() < 0.03) g.terrain[i] = GRASS
841 else if (t === GRASS && nt === FOREST && rnd() < 0.04 && (g.house[i] ?? 0) === 0) g.terrain[i] = FOREST
842 else if (t === LAVA) {
843 if (isWater(nt) || nt === ICE || nt === SNOW) { g.terrain[i] = MOUNTAIN; if (nt !== SNOW) g.terrain[j] = SHALLOW }
844 else if (rnd() < 0.25) ignite(g, x + dx, y + dy)
845 if (rnd() < 0.02) g.terrain[i] = MOUNTAIN
846 } else if (t === CORRUPTED) {
847 if (!isWater(nt) && nt !== CORRUPTED && nt !== MOUNTAIN && rnd() < 0.05) g.terrain[j] = CORRUPTED
848 if (rnd() < 0.002 && g.units.filter(u => u.k === 'demon').length < 6) {
849 const d = spawn(g, 'demon', x, y)
850 if (d !== null) log(g, 'A demon claws its way out of the corrupted land.')
851 }
852 } else if (t === ICE && nt === LAVA) g.terrain[i] = SHALLOW
853 }
854}
855
856function fxStep(g: Game) {
857 for (const f of g.fx) {
858 f.ttl--
859 if (f.kind === 'rain') {
860 for (let n = 0; n < 6; n++) {
861 const x = f.x + ri(f.r * 2 + 1) - f.r, y = f.y + ri(f.r * 2 + 1) - f.r
862 if (!inBounds(x, y)) continue
863 const i = y * W + x
864 g.fire[i] = 0
865 const t = g.terrain[i]
866 if ((t === SCORCHED || t === DESERT) && rnd() < 0.3) g.terrain[i] = GRASS
867 else if (t === GRASS && rnd() < 0.08) g.terrain[i] = FOREST
868 else if (t === LAVA && rnd() < 0.3) g.terrain[i] = MOUNTAIN
869 }
870 } else if (f.kind === 'tornado') {
871 const [dx, dy] = pick(DIRS)
872 f.x = Math.max(0, Math.min(W - 1, f.x + dx)); f.y = Math.max(0, Math.min(H - 1, f.y + dy))
873 for (let yy = f.y - 1; yy <= f.y + 1; yy++) for (let xx = f.x - 1; xx <= f.x + 1; xx++) {
874 if (!inBounds(xx, yy)) continue
875 const i = yy * W + xx
876 if ((g.house[i] ?? 0) !== 0) damageHouse(g, i, 25)
877 if (g.terrain[i] === FOREST && rnd() < 0.4) g.terrain[i] = GRASS
878 g.fire[i] = 0
879 }
880 for (const u of unitsIn(g, f.x, f.y, 1)) {
881 if (u.k === 'dragon' || u.k === 'ufo') continue
882 hurt(g, u, 6)
883 const nx = u.x + ri(7) - 3, ny = u.y + ri(7) - 3
884 if (inBounds(nx, ny)) { u.x = nx; u.y = ny }
885 }
886 } else if (f.kind === 'volcano') {
887 const r = 1 + ri(3)
888 const x = f.x + ri(r * 2 + 1) - r, y = f.y + ri(r * 2 + 1) - r
889 if (inBounds(x, y) && rnd() < 0.6) {
890 const i = y * W + x
891 g.terrain[i] = LAVA; g.house[i] = 0
892 for (const [dx, dy] of DIRS) if (rnd() < 0.3) ignite(g, x + dx, y + dy)
893 }
894 }
895 }
896 g.fx = g.fx.filter(f => f.ttl > 0)
897}
898
899function step(g: Game) {
900 g.tick++
901 rebuildIndex(g)
902 for (const u of g.units) if (u.hp > 0) act(g, u)
903 tiles(g)
904 fxStep(g)
905 if (g.tick % 12 === 0) kingdomsQuarter(g)
906 if (g.tick % TICKS_PER_YEAR === 0) kingdomsYear(g)
907 const dead = g.units.filter(u => u.hp <= 0)
908 if (dead.length > 0) {
909 for (const u of dead) {
910 const k = g.kingdoms[u.kd]
911 if (k?.king === u.id && u.age <= u.life) log(g, `${u.name}, ruler of ${k.name}, has been slain!`)
912 if (u.id === g.selected) { g.selected = -1; g.follow = false }
913 }
914 g.units = g.units.filter(u => u.hp > 0)
915 }
916}
917
918// -------------------------------------------------------------- tools ---
919
920type Tool = { id: string; label: string; cat: Cat; drag: boolean; apply: (g: Game, x: number, y: number, r: number) => void }
921
922function disc(x: number, y: number, r: number, fn: (x: number, y: number, i: number) => void) {
923 for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
924 if (dx * dx + dy * dy > r * r + r || !inBounds(x + dx, y + dy)) continue
925 fn(x + dx, y + dy, (y + dy) * W + x + dx)
926 }
927}
928
929function paint(t: number) {
930 return (g: Game, x: number, y: number, r: number) => disc(x, y, r, (_x, _y, i) => {
931 g.terrain[i] = t
932 g.fire[i] = 0
933 if (isWater(t) || t === LAVA || t === MOUNTAIN) g.house[i] = 0
934 })
935}
936
937function heat(g: Game, x: number, y: number, r: number) {
938 disc(x, y, r, (xx, yy, i) => {
939 if (rnd() < 0.5) return
940 const t = g.terrain[i]
941 if (t === ICE) g.terrain[i] = SHALLOW
942 else if (t === SNOW) g.terrain[i] = GRASS
943 else if (t === SHALLOW && rnd() < 0.3) g.terrain[i] = SAND
944 else if (t === GRASS || t === FOREST || t === SWAMP) { g.terrain[i] = DESERT; if (rnd() < 0.3) ignite(g, xx, yy) }
945 else if ((t === SAND || t === DESERT || t === MOUNTAIN || t === HILLS || t === SCORCHED) && rnd() < 0.4) { g.terrain[i] = LAVA; g.house[i] = 0 }
946 })
947}
948
949function cool(g: Game, x: number, y: number, r: number) {
950 disc(x, y, r, (_x, _y, i) => {
951 if (rnd() < 0.5) return
952 g.fire[i] = 0
953 const t = g.terrain[i]
954 if (t === LAVA) g.terrain[i] = MOUNTAIN
955 else if (t === SHALLOW || (t === DEEP && rnd() < 0.3)) g.terrain[i] = ICE
956 else if (t === GRASS || t === FOREST || t === SWAMP || t === SCORCHED) g.terrain[i] = SNOW
957 else if (t === DESERT) g.terrain[i] = SAND
958 })
959}
960
961function explode(g: Game, x: number, y: number, r: number, power: number, kind: Fx['kind'] = 'boom') {
962 disc(x, y, r, (xx, yy, i) => {
963 const t = g.terrain[i] ?? DEEP
964 g.house[i] = 0
965 if (!isWater(t)) {
966 const core = (xx - x) ** 2 + (yy - y) ** 2 <= (r * 0.5) ** 2
967 g.terrain[i] = core && r >= 2 ? (t === MOUNTAIN || t === HILLS ? HILLS : r >= 6 ? SHALLOW : SCORCHED) : SCORCHED
968 if (rnd() < 0.5) ignite(g, xx, yy)
969 }
970 })
971 for (const u of unitsIn(g, x, y, r)) hurt(g, u, power)
972 g.fx.push({ kind, x, y, r, ttl: kind === 'nuke' ? 10 : 4 })
973}
974
975function spawnTool(k: Kind) {
976 return (g: Game, x: number, y: number, r: number) => {
977 const n = KINDS[k].hp >= 100 ? 1 : Math.max(1, r * 2)
978 let placed = 0
979 for (let i = 0; i < n; i++) {
980 // A dragon, demon or UFO lands right where it is dropped; a herd spreads over the brush.
981 const u = spawnNear(g, k, x, y, KINDS[k].hp >= 100 ? 0 : r, MAX_UNITS + GOD_RESERVE)
982 if (u === null) continue
983 placed++
984 if (KINDS[k].hp >= 100) log(g, `A ${KINDS[k].label.toLowerCase()} appears!`)
985 }
986 // Say why nothing appeared, rather than leave the click unanswered.
987 if (placed === 0) {
988 g.flash = g.units.length >= MAX_UNITS + GOD_RESERVE
989 ? `The world is full (${g.units.length} creatures): remove some life first.`
990 : `${KINDS[k].label} can't live there: try land${k === 'dwarf' ? ' or mountains' : ''}.`
991 g.flashTtl = 30
992 }
993 }
994}
995
996const TOOLS: Tool[] = [
997 { id: 'deep', label: 'Ocean', cat: 'terrain', drag: true, apply: paint(DEEP) },
998 { id: 'shallow', label: 'Shallows', cat: 'terrain', drag: true, apply: paint(SHALLOW) },
999 { id: 'sand', label: 'Sand', cat: 'terrain', drag: true, apply: paint(SAND) },
1000 { id: 'grass', label: 'Grass', cat: 'terrain', drag: true, apply: paint(GRASS) },
1001 { id: 'forest', label: 'Forest', cat: 'terrain', drag: true, apply: paint(FOREST) },
1002 { id: 'hills', label: 'Hills', cat: 'terrain', drag: true, apply: paint(HILLS) },
1003 { id: 'mountain', label: 'Mountain', cat: 'terrain', drag: true, apply: paint(MOUNTAIN) },
1004 { id: 'desert', label: 'Desert', cat: 'terrain', drag: true, apply: paint(DESERT) },
1005 { id: 'tundra', label: 'Tundra', cat: 'terrain', drag: true, apply: paint(SNOW) },
1006 { id: 'swamp', label: 'Swamp', cat: 'terrain', drag: true, apply: paint(SWAMP) },
1007 { id: 'enchanted', label: 'Enchanted', cat: 'terrain', drag: true, apply: paint(ENCHANTED) },
1008 { id: 'corrupt', label: 'Corrupted', cat: 'terrain', drag: true, apply: paint(CORRUPTED) },
1009 { id: 'heat', label: 'Heat', cat: 'terrain', drag: true, apply: heat },
1010 { id: 'cool', label: 'Cool', cat: 'terrain', drag: true, apply: cool },
1011
1012 { id: 'rain', label: 'Rain', cat: 'nature', drag: true, apply: (g, x, y, r) => g.fx.push({ kind: 'rain', x, y, r: r + 2, ttl: 24 }) },
1013 {
1014 id: 'lightning', label: 'Lightning', cat: 'nature', drag: false, apply: (g, x, y) => {
1015 for (const u of unitsIn(g, x, y, 1)) hurt(g, u, 45)
1016 ignite(g, x, y)
1017 if ((g.house[y * W + x] ?? 0) !== 0) damageHouse(g, y * W + x, 40)
1018 g.fx.push({ kind: 'flash', x, y, r: 0, ttl: 3 })
1019 },
1020 },
1021 { id: 'fire', label: 'Fire', cat: 'nature', drag: true, apply: (g, x, y, r) => disc(x, y, Math.max(0, r - 1), (xx, yy) => ignite(g, xx, yy)) },
1022 { id: 'tornado', label: 'Tornado', cat: 'nature', drag: false, apply: (g, x, y) => { g.fx.push({ kind: 'tornado', x, y, r: 1, ttl: 70 }); log(g, 'A tornado touches down.') } },
1023 {
1024 id: 'quake', label: 'Earthquake', cat: 'nature', drag: false, apply: (g, x, y) => {
1025 disc(x, y, 7, (_x, _y, i) => {
1026 if (rnd() < 0.5 && (g.house[i] ?? 0) !== 0) damageHouse(g, i, 40)
1027 if (rnd() > 0.25) return
1028 const t = g.terrain[i] ?? DEEP
1029 g.terrain[i] = rnd() < 0.15 && !isWater(t) ? SHALLOW : t === HILLS ? MOUNTAIN : isWater(t) ? t : HILLS
1030 if (isWater(g.terrain[i] ?? DEEP)) g.house[i] = 0
1031 })
1032 for (const u of unitsIn(g, x, y, 7)) hurt(g, u, 8)
1033 log(g, 'The earth shakes!')
1034 },
1035 },
1036 {
1037 id: 'volcano', label: 'Volcano', cat: 'nature', drag: false, apply: (g, x, y) => {
1038 disc(x, y, 2, (_x, _y, i) => { g.terrain[i] = MOUNTAIN; g.house[i] = 0 })
1039 g.terrain[y * W + x] = LAVA
1040 g.fx.push({ kind: 'volcano', x, y, r: 3, ttl: 60 })
1041 log(g, 'A volcano erupts!')
1042 },
1043 },
1044
1045 { id: 'human', label: 'Humans', cat: 'life', drag: true, apply: spawnTool('human') },
1046 { id: 'elf', label: 'Elves', cat: 'life', drag: true, apply: spawnTool('elf') },
1047 { id: 'orc', label: 'Orcs', cat: 'life', drag: true, apply: spawnTool('orc') },
1048 { id: 'dwarf', label: 'Dwarves', cat: 'life', drag: true, apply: spawnTool('dwarf') },
1049 { id: 'sheep', label: 'Sheep', cat: 'life', drag: true, apply: spawnTool('sheep') },
1050 { id: 'chicken', label: 'Chickens', cat: 'life', drag: true, apply: spawnTool('chicken') },
1051 { id: 'wolf', label: 'Wolves', cat: 'life', drag: true, apply: spawnTool('wolf') },
1052 { id: 'bear', label: 'Bears', cat: 'life', drag: true, apply: spawnTool('bear') },
1053 { id: 'dragon', label: 'Dragon', cat: 'life', drag: false, apply: spawnTool('dragon') },
1054 { id: 'demon', label: 'Demon', cat: 'life', drag: false, apply: spawnTool('demon') },
1055 { id: 'ufo', label: 'UFO', cat: 'life', drag: false, apply: spawnTool('ufo') },
1056
1057 { id: 'dynamite', label: 'Dynamite', cat: 'destroy', drag: true, apply: (g, x, y) => explode(g, x, y, 1, 30) },
1058 { id: 'bomb', label: 'Bomb', cat: 'destroy', drag: false, apply: (g, x, y) => explode(g, x, y, 3, 70) },
1059 { id: 'napalm', label: 'Napalm', cat: 'destroy', drag: false, apply: (g, x, y) => { disc(x, y, 5, (xx, yy) => { if (rnd() < 0.7) ignite(g, xx, yy) }); g.fx.push({ kind: 'boom', x, y, r: 5, ttl: 3 }) } },
1060 { id: 'atomic', label: 'Atomic', cat: 'destroy', drag: false, apply: (g, x, y) => { explode(g, x, y, 9, 999, 'nuke'); disc(x, y, 13, (xx, yy) => { if (rnd() < 0.15) ignite(g, xx, yy) }); log(g, 'An atomic bomb detonates!') } },
1061 {
1062 id: 'antimatter', label: 'Antimatter', cat: 'destroy', drag: false, apply: (g, x, y) => {
1063 disc(x, y, 6, (_x, _y, i) => { g.terrain[i] = DEEP; g.house[i] = 0; g.fire[i] = 0 })
1064 for (const u of unitsIn(g, x, y, 6)) u.hp = -999
1065 g.fx.push({ kind: 'magic', x, y, r: 6, ttl: 5 })
1066 log(g, 'Antimatter erases a piece of the world.')
1067 },
1068 },
1069
1070 { id: 'plague', label: 'Plague', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) if (!u.traits.includes('immune') && !isMonster(u.k)) u.sick = 1; g.fx.push({ kind: 'plague', x, y, r, ttl: 6 }) } },
1071 {
1072 id: 'zombie', label: 'Zombies', cat: 'magic', drag: true, apply: (g, x, y, r) => {
1073 let turned = 0
1074 for (const u of unitsIn(g, x, y, r)) if (isRace(u.k) && !u.traits.includes('immune')) { zombify(u); turned++ }
1075 g.fx.push({ kind: 'plague', x, y, r, ttl: 6 })
1076 if (turned > 0 || spawnNear(g, 'zombie', x, y, r, MAX_UNITS + GOD_RESERVE) !== null) return
1077 g.flash = "Zombies can't rise there: try land, or people who aren't immune."
1078 g.flashTtl = 30
1079 },
1080 },
1081 { id: 'madness', label: 'Madness', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) if (isRace(u.k)) u.mad = 120; g.fx.push({ kind: 'magic', x, y, r, ttl: 5 }) } },
1082 { id: 'heal', label: 'Healing', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) { u.hp = u.max; u.sick = 0; u.mad = 0 } g.fx.push({ kind: 'heal', x, y, r, ttl: 6 }) } },
1083 { id: 'bless', label: 'Blessing', cat: 'magic', drag: false, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) addTrait(u, pick(GOOD_TRAITS)); g.fx.push({ kind: 'heal', x, y, r, ttl: 6 }) } },
1084 { id: 'curse', label: 'Curse', cat: 'magic', drag: false, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) addTrait(u, pick(BAD_TRAITS)); g.fx.push({ kind: 'magic', x, y, r, ttl: 6 }) } },
1085
1086 { id: 'inspect', label: 'Inspect', cat: 'inspect', drag: false, apply: (g, x, y) => inspect(g, x, y) },
1087 { id: 'erase', label: 'Remove life', cat: 'inspect', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, Math.max(0, r - 1))) u.hp = -999 } },
1088]
1089
1090const CATS: [Cat, string][] = [['terrain', 'Terrain'], ['nature', 'Nature'], ['life', 'Life'], ['destroy', 'Destroy'], ['magic', 'Magic'], ['inspect', 'Inspect']]
1091
1092function addTrait(u: Unit, t: Trait) {
1093 if (!isRace(u.k) || u.traits.includes(t)) return
1094 u.traits.push(t)
1095 if (t === 'wise') u.life = Math.round(u.life * 1.5)
1096 applyTraits(u)
1097}
1098
1099function removeTrait(u: Unit, t: Trait) {
1100 u.traits = u.traits.filter(x => x !== t)
1101 if (t === 'wise') u.life = Math.round(u.life / 1.5)
1102 applyTraits(u)
1103}
1104
1105function inspect(g: Game, x: number, y: number) {
1106 let best: Unit | null = null, bd = 3
1107 for (const u of g.units) {
1108 const d = dist(u.x, u.y, x, y)
1109 if (u.hp > 0 && d < bd) { bd = d; best = u }
1110 }
1111 g.selected = best?.id ?? -1
1112 if (best === null) {
1113 const h = g.house[y * W + x] ?? 0
1114 g.flash = h !== 0 ? `A house of ${g.kingdoms[h - 1]?.name ?? '?'} on ${TERRAIN_NAME[g.terrain[y * W + x] ?? 0]}` : TERRAIN_NAME[g.terrain[y * W + x] ?? 0] ?? ''
1115 g.flashTtl = 30
1116 }
1117}
1118
1119// --------------------------------------------------------------- saves ---
1120
1121type Save = { v: 1; tick: number; terrain: string; houses: number[]; units: (string | number)[][]; kingdoms: Kingdom[]; log: string[]; nextId: number }
1122
1123function toSave(g: Game): Save {
1124 const houses: number[] = []
1125 for (let i = 0; i < W * H; i++) if ((g.house[i] ?? 0) !== 0) houses.push(i, g.house[i] ?? 0, g.hhp[i] ?? 0)
1126 return {
1127 v: 1, tick: g.tick, nextId: g.nextId, log: g.log.slice(-30),
1128 terrain: String.fromCharCode(...Array.from(g.terrain, t => 65 + t)),
1129 houses,
1130 units: g.units.map(u => [u.id, u.k, u.x, u.y, Math.round(u.hp), u.age, u.life, u.kd, u.name,
1131 u.traits.reduce((m, t) => m | (1 << TRAITS.indexOf(t)), 0), u.sick, u.mad]),
1132 kingdoms: g.kingdoms,
1133 }
1134}
1135
1136function fromSave(g: Game, frozen: Save) {
1137 // Props arrive frozen; the world needs its own copy to live in.
1138 const s = JSON.parse(JSON.stringify(frozen)) as Save
1139 if (s.v !== 1 || s.terrain.length !== W * H) return
1140 for (let i = 0; i < W * H; i++) g.terrain[i] = s.terrain.charCodeAt(i) - 65
1141 g.house.fill(0); g.hhp.fill(0); g.fire.fill(0)
1142 for (let n = 0; n + 2 < s.houses.length; n += 3) { g.house[s.houses[n] ?? 0] = s.houses[n + 1] ?? 0; g.hhp[s.houses[n] ?? 0] = s.houses[n + 2] ?? 0 }
1143 g.kingdoms = s.kingdoms
1144 g.units = []
1145 for (const r of s.units) {
1146 const [id, k, x, y, hp, age, life, kd, name, bits, sick, mad] = r as [number, Kind, number, number, number, number, number, number, string, number, number, number]
1147 if (!(k in KINDS)) continue
1148 const u: Unit = { id, k, x, y, hp, max: 0, dmg: 0, arm: 0, spd: 1, age, life, kd, name, traits: TRAITS.filter((_t, b) => (bits & (1 << b)) !== 0), sick, mad, tgt: -1, tx: -1, ty: -1, mode: 0, cd: 0 }
1149 applyTraits(u)
1150 u.hp = Math.min(hp, u.max)
1151 g.units.push(u)
1152 }
1153 g.tick = s.tick; g.nextId = s.nextId; g.log = s.log; g.fx = []; g.selected = -1; g.follow = false
1154 rebuildIndex(g)
1155 kingdomsQuarter(g)
1156 log(g, 'The world is restored from a save.')
1157}
1158
1159// -------------------------------------------------------------- input ---
1160
1161const toolOf = (g: Game) => TOOLS.find(t => t.id === g.tool) ?? (TOOLS[0] as Tool)
1162
1163function tileAt(g: Game, px: number, py: number): [number, number] | null {
1164 if (g.desktop) {
1165 if (g.cols <= 0 || g.rows <= 0 || px < 0 || py < 0 || px >= g.cols || py >= g.rows) return null
1166 const x = g.camX + Math.floor(((px + 0.5) * desktopVw(g)) / g.cols), y = g.camY + Math.floor(((py + 0.5) * g.dvh) / g.rows)
1167 return inBounds(x, y) ? [x, y] : null
1168 }
1169 const row = py - g.mapTop
1170 if (row < 0 || row >= g.mapRows || g.view !== 'map') return null
1171 const x = g.camX + Math.floor(px / g.drawZoom), y = g.camY + row
1172 return inBounds(x, y) ? [x, y] : null
1173}
1174
1175function onPointer(g: Game, e: ClientPointerEvent): boolean {
1176 const tile = tileAt(g, e.x, e.y)
1177 if (e.type === 'leave') { g.hover = null; g.painting = false; g.panning = null; return true }
1178 if (e.type === 'up') { g.painting = false; g.panning = null; return false }
1179 if (e.type === 'down' && (e.button === 'right' || e.button === 'middle')) {
1180 g.panning = { x: e.x, y: e.y, camX: g.camX, camY: g.camY }
1181 return false
1182 }
1183 if (e.type === 'move' && g.panning !== null) {
1184 g.camX = g.panning.camX - Math.round((e.x - g.panning.x) / g.zoom)
1185 g.camY = g.panning.camY - (e.y - g.panning.y)
1186 g.follow = false
1187 return true
1188 }
1189 if (e.type === 'down') {
1190 if (tile === null) {
1191 if (g.desktop) return false
1192 const hit = g.regions.find(r => r.row === e.y && e.x >= r.x0 && e.x < r.x1)
1193 hit?.act()
1194 return hit !== undefined
1195 }
1196 g.painting = true
1197 g.lastPaint = -1
1198 }
1199 if (e.type === 'move') {
1200 const was = g.hoverhooks/controls.tsx 34 lines1import type { ClientModule, JsonValue } from 'claude-code'
2
3/*
4 * The desktop's buttons and dropdowns. They live in a Client of their own so
5 * their handlers stay put: the pane around them redraws with every frame of
6 * the map, and a Button drawn by the pane would lose its press to the next
7 * drawing. This region redraws only when its own props change. A press posts
8 * the command; the hooks module hands it on to the game.
9 */
10
11type Choice = { key: string; value: string; act: string; options: { value: string; label: string }[] }
12type Press = { key: string; label: string; act: string; active?: boolean }
13export type ControlsProps = { label?: string; selects?: Choice[]; buttons: Press[] }
14
15const Controls: ClientModule<JsonValue> = (props, s) => {
16 const { Box, Text, Button, Select } = s.elements
17 const p = (props ?? { buttons: [] }) as unknown as ControlsProps
18 const send = (act: string) => s.post({ type: 'cmd', act })
19
20 return (
21 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
22 {p.label !== undefined ? <Text dimColor>{p.label}</Text> : null}
23 {(p.selects ?? []).map(c => (
24 <Select key={c.key} value={c.value} options={c.options} onSelect={v => send(`${c.act}:${v}`)} />
25 ))}
26 {p.buttons.map(b => (
27 <Button key={b.key} variant={b.active === true ? 'primary' : 'secondary'} onPress={() => send(b.act)}>{b.label}</Button>
28 ))}
29 </Box>
30 )
31}
32
33export default Controls
34types/index.d.ts 72 lines1/**
2 * One frame of the world as the desktop draws it: the game runs in the
3 * Client, which posts what is in view; the hooks module paints it as SVG.
4 * Coordinates are tiles relative to the view's top left.
5 */
6export type OmniFrame = {
7 vw: number
8 vh: number
9 /** The view's top left in world tiles, so decorations stay put as it pans. */
10 camX: number
11 camY: number
12 tick: number
13 /** `vw * vh` characters, one per tile: `'A'` plus the terrain code. */
14 terr: string
15 /** With borders on, `vw * vh` characters: `'0'` none, else `'0'` plus kingdom id plus one. */
16 owner: string
17 /** Burning tiles, as `y * vw + x`. */
18 fire: number[]
19 /** Triplets: tile index, kingdom id, 1 for the capital. */
20 houses: number[]
21 /** Sextuplets: x, y, kind index, kingdom id or -1, flags (1 ruler, 2 sick, 4 mad, 8 selected), id. */
22 units: number[]
23 /** Quintuplets: effect index, x, y, radius, frames left. */
24 fx: number[]
25 /** Empty, or x, y, radius of the brush under the pointer. */
26 hover: number[]
27 /** Kingdom colours by id. */
28 colors: string[]
29 hud: OmniHud
30 info: OmniInfo
31 realms: OmniRealm[]
32 log: string[]
33}
34
35export type OmniHud = {
36 year: number
37 pop: number
38 kingdoms: number
39 wars: number
40 beasts: number
41 cat: string
42 tool: string
43 toolLabel: string
44 paused: boolean
45 speed: number
46 brush: number
47 zoom: number
48 borders: boolean
49 view: 'map' | 'realms'
50 /** The last command the game applied: what the panel's buttons sent, acknowledged. */
51 ack: number
52 /** Rows of text the map's box takes on the desktop, which the input region spans. */
53 rows: number
54}
55
56export type OmniTrait = { name: string; good: boolean }
57
58export type OmniInfo =
59 | { kind: 'log' }
60 | { kind: 'unit'; name: string; color: string; line: string; traits: OmniTrait[]; canTrait: boolean; follow: boolean }
61
62export type OmniRealm = { name: string; color: string; text: string; wars: string; allies: string }
63
64/** A press on a desktop Button, handed to the Client through its props. */
65export type OmniCmd = { seq: number; act: string }
66
67declare module 'claude-code' {
68 interface PluginState {
69 omnirealm: { frame: OmniFrame; cmds: OmniCmd[] }
70 }
71}
72