A roguelike in a pane, driven by the session's coding: each tool call moves the hero, a failure lets the foe hit back, a turn's end takes the stairs.

A Claude Code mod: a pixel roguelike in a pane, played by your coding session. Each tool call moves the hero, a failure lets the foe hit back, and real work pays.
![]()
| In the session | In the dungeon |
|---|---|
| Every tool call | A step or a blow |
| A failed call | The foe strikes |
| Tests that pass | A sure critical |
| A commit | A shrine and full HP |
| A push or a PR | A cache of loot |
| An answer finished | The stairs down |
The same list is in the game, under How it plays. What stays yours: the gear, the talents, and the standing orders in settings (when to drink, when to throw smoke, what loot to leave). Through the portal is the town: a merchant, a smith, a healer, a stash, an elder with quests, a mystic with fortunes, a villager with sealed packs and a guard who trades three spare pieces for one better.
Requires a Claude Code with mods. Developed and tested on 2.1.309. The game is drawn for a dark Claude Code theme; on a light theme some of its text is hard to read.
From the ilovepixelart marketplace, which pins each mod to its latest release:
/plugin marketplace add ilovepixelart/claude-code-mods
/plugin install ascii-dungeon@ilovepixelart
Or in one line, straight from this repository, following main:
/plugin install ascii-dungeon --marketplace ilovepixelart/ascii-dungeon-mod
To stay on one release, add this repository at its tag instead, then install from it:
/plugin marketplace add ilovepixelart/ascii-dungeon-mod#ascii-dungeon--v0.5.0
/plugin install ascii-dungeon@ascii-dungeon-mod
Run /reload-plugins (or start a new session) after installing. To take a new release later, run claude plugin update ascii-dungeon@ilovepixelart in your shell, or ascii-dungeon@ascii-dungeon-mod if you installed from this repository. Each release also carries a zip of the plugin for claude --plugin-url, with its SHA-256 beside it, and CHANGELOG.md lists what each one changed.
To try it from a clone without installing: claude --plugin-dir /path/to/ascii-dungeon-mod.
/dungeon opens the pane. The pixel view needs a terminal that draws truecolor half blocks, and the mouse needs the fullscreen layout (/tui fullscreen).
| Pane | Shows |
|---|---|
| 105 columns or wider | the map, the strip and every window in pixels |
| 80 to 104 columns | the map and the strip in pixels, a compact inventory |
| narrower | the game as text |
| 62 rows or taller | every window whole with its keys; a shorter pane scrolls, and says by how much on its top row |
With the mouse: click the map to walk there (a foe, to charge it). On a pane of 105 columns or more the dash under it has an HP orb and an XP orb (either opens the character sheet), the belt (a slot drinks), buttons in its corners for the bag, settings and the portal to town, and a plaque of depth and gold over the map (the log); narrower, the strip's portrait opens the inventory, the HP and XP bars the sheet, and the gear and the portal their own. Every window lists its keys under it, and c closes it.
As text, the keys are i inventory, c character, a attack, p potion, w explore, d descend, t portal, h j k l to move, z zoom and r new game.
<table> <tr> <td><img src="docs/img/start-knight.png" alt="The start screen: knight, mage and rogue cards"></td> <td><img src="docs/img/talent-0.png" alt="The talent window: three talent cards side by side"></td> </tr> <tr> <td><img src="docs/img/deep-brittle-ironclad.png" alt="The Abyss: a fork between a blessing and a curse for the next circle"></td> <td><img src="docs/img/talk-mystic.png" alt="The mystic in town with her three fortunes"></td> </tr> <tr> <td><img src="docs/img/chest-knight.png" alt="The stash: a tab's grid over the bag's, its tabs and the gold in the title bar"></td> <td><img src="docs/img/codex-foes.png" alt="The codex: the uniques and the big bosses, the slain ones drawn, the rest dark shapes"></td> </tr> </table>
Options in the plugin's config: sound (effects) and music (the town's tune), both on by default.
| Command | Does |
|---|---|
node scripts/gates.mjs --typecheck | every gate, in its order: release check, colors, font, strict validate, tests, ruff and the unit tests over the Python tools, typecheck (CI runs all but the typecheck, which needs the plugin opened once interactively) |
claude plugin test . | every test |
claude plugin validate . | what the engine would refuse |
bun tools/simulate.ts <out> --games 100 --steps 1000 | plays games through the rules, every invariant checked each step, and reports balance |
bun tools/feel.ts 100 1000 | danger, pace and variety per class: deaths and close calls per 100 tool calls, quiet stretches, foe kinds |
uv run --with pillow python tools/shots/shoot.py <out> [name...] | every window as a real Claude Code session draws it: a copy of the mod in a private tmux, each scene of tools/shots/scenes.ts opened, captured cell for cell and painted as Ghostty shows it, into <out>/gallery.html |
bun tools/readme.ts | the README's pictures, into docs/img/ |
python3 tools/sounds.py | builds the sound clips into sounds/ and hooks/clips.ts |
python3 tools/measure.py | frames as a real terminal receives them, during /dungeon-bench |
Rule changes go through the simulator; visual changes through a mockup round first (see .claude/CLAUDE.md).
docs/architecture.md (how the pane plays), docs/design.md (the design language every pixel surface follows), docs/items.md (classes, items, foes, loot), docs/pacing.md (what keeps a session varied, and the numbers), docs/town.md, docs/windows.md (every pixel window), docs/inventory.md, docs/dashboard.md (the strip). Changes by version: CHANGELOG.md.
Releases follow Semantic Versioning. While the version is 0.x, any release may change behaviour. The game is saved in a versioned layout (SAVE_VERSION): a release brings an older save up to its own, and never reads or overwrites a save from a newer layout than it knows, and says so instead.
MIT, see LICENSE.
hooks/register.tsx 2321 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, RenderElement, RenderInput, ToolCallInput } from 'claude-code'
3
4import type { Game, HeroClass, Talent } from '../types'
5import { TOWN_TALK, isTownWalkable, townSpotAt } from './town'
6import { BIG_BOSSES, LOG_KEPT, UNIQUES, alongTheWay, apply, canRetire, classAbout, firstCodex, fortuneCost, gambleCost, isOnlyAWalk, keptChoice, newGame, normalize, onToolStart, priceOfWare, questLine, questReward, reforgeCost, threat, tradeInOf, whyNotRetire } from './game'
7import type { Action, Run } from './game'
8import { BELT_SIZE, nameOf, wareOf } from './consumables'
9import { FRAME_MS, LATE_MS, createSamples, createTicker, summarize } from './frames'
10import type { FrameSummary, TickStats } from './frames'
11import { OVER_ROWS, isDash, isMapUnder, mapCovered, orbPhase, overMapBoxes, overMapGesture, stripBeltMenu, stripBoxes, stripGesture, stripHeight, stripHot, stripOverMap, stripPixels } from './strip'
12import type { BeltMenu } from './hud'
13import { deathGesture, deathHeight, deathHot, deathOverlay, deathPixels, killerName } from './death'
14import { recapGesture, recapHeight, recapHot, recapOverlay, recapPixels } from './recapwindow'
15import { CODEX_UI, codexGesture, codexHeight, codexHot, codexOverlay, codexPixels } from './codexwindow'
16import type { CodexUi } from './codexwindow'
17import { cardPixels, cardText, svgOf } from './herocard'
18import { retireGesture, retireHeight, retireHot, retireOverlay, retirePixels } from './retirewindow'
19import { TALENT_UI, talentGesture, talentHeight, talentHot, talentOverlay, talentPixels } from './talentwindow'
20import type { TalentUi } from './talentwindow'
21import { TALENTS, TALENT_EVERY, offer, owed } from './talents'
22import { deepGesture, deepHeight, deepHot, deepOverlay, deepPixels } from './deepwindow'
23import { DEEP_FROM, MARKS, circleSaid, fateOf, forkTier, isForkOpen, markOf, pairOf, roman, tierOf } from './deep'
24import type { RetireUi } from './retirewindow'
25import { SETTINGS_H, SETTINGS_UI, VOLUME_STEPS, nextSound, orderWord, settingsGesture, settingsWord, settingsHot, settingsOverlay, settingsPixels, soundFrom, volumeShare } from './settings'
26import type { SettingsUi } from './settings'
27import { HELP_ROWS, helpGesture, helpHeight, helpOverlay, helpPixels } from './helpwindow'
28import { logGesture, logHeight, logOverlay, logPixels } from './logwindow'
29import type { SoundSettings } from './settings'
30import { INV_H, INV_TOP, INV_W, beltLabel, inventoryScreenPixels, itemCard } from './inventory'
31import { BAG_UI_IDLE, bagGesture, bagOverlay, card, inventoryHot } from './bagui'
32import type { BagUi, Gesture } from './bagui'
33import { SHOP_H, SHOP_UI_IDLE, WARES, nextWare, shopGesture, shopHot, shopOverlay, shopPixels, whyNot } from './shop'
34import type { ShopUi } from './shop'
35import { chromeBoxes, closeBoxOf, onPixels, pixel } from './window'
36import type { OverlayBox, Rect } from './window'
37import { capitalized, countText } from './names'
38import { hoveredRect, lightUp } from './ui/kit'
39import { heroTabsHot } from './herotabs'
40import { canvasCells } from './canvas'
41import { FORGE_UI_IDLE, chosenSlot, forgeGesture, forgeHeight, forgeHot, forgeOverlay, forgePixels, nextWorn, whyNotReforge, whyNotTemper } from './forge'
42import type { ForgeUi } from './forge'
43import { CHEST_H, CHEST_UI_IDLE, chestGesture, chestHot, chestOverlay, chestPixels, idleOn, nextStored, nextTab, storeOn, whyNotStore, whyNotTake } from './chest'
44import type { ChestUi } from './chest'
45import { stashedItems, tabItems, whyNotBuy } from './stash'
46import { HINT_ROWS, UNDER_WINDOW, fitNote, sizeNote } from './panesize'
47import { CLASSES as HEROES, STATS_LINE, begin, isUnderWay, startGesture, startHeight, startHot, startOverlay, startPixels } from './start'
48import type { StartUi } from './start'
49import { isFromLater, isReadable, openingGame, savable } from './save'
50import { SHEET_H, sheetGesture, sheetHot, sheetOverlay, sheetPixels } from './sheet'
51import { COMPACT_H, COMPACT_W, compactGesture, compactHot, compactOverlay, compactPixels } from './compact'
52import { FORTUNE_NAME, isSpeaker, talkAction, talkGesture, talkHeight, talkHot, talkOverlay, talkPixels } from './talk'
53import type { Speaker } from './talk'
54import { CLASS_NAME, LEGENDARIES, RARITY_COLOR, SLOTS, TEMPER_MAX, isUpgrade, junkValue, temperCost, whyNotBuyBack } from './items'
55import { MAP_H, MAP_W, cellId, isFloor, same } from './map'
56import { levelOf } from './world'
57import { ATTRIBUTES, bagRows, foeRuns, logRuns, lyingLabel, mapRows, statusRuns } from './panels'
58import { PX } from './pixelart'
59import { INK } from './ui/tokens'
60import { FRAMES_PER_TILE, STILL, layerBuildCount, stampCells, walkState, benchRoute, clickToTile, labelGrounds, labelRowAt, labelWidth, mapLabelAt, pixelMapFrame, tileRect, tileToCell, deathScene, hitFramesFor, hitsBetween, isStrideFrame, pixelMap, tileCamera, walkPosition, walkingGame } from './pixels'
61import type { Ambient, Hit, PixelRect } from './pixels'
62import { base64Of, bytesOf, mixdown, readWav, walkCues, wavOf } from './mixdown'
63import { workOf } from './work'
64import type { GitOperation } from './work'
65import { HOST_PLACES, MUSIC_GAIN, cuesFor, gainOf, mixFor, musicFor, soundAsset, soundsFor, stepAt } from './sounds'
66import type { Player, Sound } from './sounds'
67import { CLIP_MS, TUNE_MS } from './clips'
68import { PX as TILE_PX } from './pixelart'
69import type { Pos } from './map'
70import { TILE_H, zoomedRows } from './sprites'
71
72const PANE = 'dungeon'
73/** Docked, the pane asks to be wide enough for the pixel inventory window and the dashboard with its belt beside the bars. */
74const OPEN = { id: PANE, title: 'Dungeon', columns: 112 }
75const SAVE = 'save'
76/** Where a save that could not be read is kept, once a new game takes its place. */
77const UNREADABLE = 'save-unreadable'
78/** The sound as set in settings, kept across sessions and reloads. */
79const SOUND = 'sound'
80/** How long the bench walk is: long enough for the camera to scroll and the torch to move many times. */
81const BENCH_TILES = 40
82/** The bench's level: a fixed one, every tile explored, so each run crosses the same lit ground whatever the save holds. */
83export const BENCH_SEED = 21
84const GAME_REF = { plugin: 'ascii-dungeon', key: 'game' } as const
85const game = atom(GAME_REF, newGame())
86
87/**
88 * The walk on screen: the tiles from where the hero stood, the frame it is on, the game as it began and the game now.
89 * The ticker plays it a frame a tick, so the hero always moves the same few pixels a frame. The game now is kept here, each
90 * action replacing it, rather than read back every frame: a read hands back a fresh copy, and what is drawn is kept
91 * per game object.
92 */
93/** The walk playing: its path and frame, the game it set out from and is going to, and, where the walk can be cut into
94 * tiles, the game on each tile and the last one shown. */
95type Walk = { path: Pos[]; frame: number; start: Game; game: Game; tiles: Game[] | null; shown: Game | null; isMixed: boolean }
96let walk: Walk | null = null
97/** The walk's own clip, its footsteps and what it picks up mixed on their frames, while it plays. */
98let walkMix: { stop: () => void } | null = null
99/** The runtime's fine clock where it has one, for timing a tick's work; the host's clock counts whole ms. */
100const fineNow = () => (globalThis as { performance?: { now: () => number } }).performance?.now() ?? 0
101const ticker = createTicker(fineNow)
102/** How long the host takes to take a repainted map, and how long drawing the whole pane takes (on every change). */
103const repaints = createSamples()
104const redraws = createSamples()
105/** Drawing a walk frame's map: with the ground layer kept, and with it drawn afresh. */
106const keptMaps = createSamples()
107const builtMaps = createSamples()
108/** The inventory screen's own state: an open menu, an item being dragged. */
109let bagUi: BagUi = BAG_UI_IDLE
110/** The belt slot whose menu of tiers is open on the strip. */
111let beltMenu: BeltMenu = null
112/** The strip's width in cells, as last drawn: what its clicks are read against. */
113let hudWidth = 0
114let shopUi: ShopUi = SHOP_UI_IDLE
115let forgeUi: ForgeUi = FORGE_UI_IDLE
116/** The piece the retire window has chosen to pass down. */
117let retireUi: RetireUi = FORGE_UI_IDLE
118let chestUi: ChestUi = CHEST_UI_IDLE
119let startUi: StartUi | null = null
120/** The card chosen in the talent window, while it is open. */
121let talentUi: TalentUi = TALENT_UI
122/** The codex's page. */
123let codexUi: CodexUi = CODEX_UI
124/** The settings window's page. */
125let settingsUi: SettingsUi = SETTINGS_UI
126/** The how-it-plays window, open over whatever the pane shows: it closes back to that. */
127let isHelpOpen = false
128/** The log window: the pane's own, as the help is, opened over whatever is open and closed back to it. */
129let isLogOpen = false
130/** The pointer over a window, in its pixels, by the window's key; and what each window drawn last can be clicked on. */
131let hover: { key: string; at: Pos } | null = null
132const hotOf = new Map<string, readonly Rect[]>()
133/** What the hover lights in the window `key`: the clickable place under the pointer, if any. */
134const litIn = (key: string) => (hover?.key === key ? hoveredRect(hotOf.get(key) ?? [], hover.at) : undefined)
135const sameRect = (a: Rect | undefined, b: Rect | undefined) => a?.x === b?.x && a?.y === b?.y && a?.w === b?.w && a?.h === b?.h
136
137/** The pointer moved over a window (or left it): drawn again only when what it lights changes. */
138const onHover = ($: EngineInterface, element: string, at: Pos | null) => {
139 const key = element.replace(/-clicks$/, '')
140 const before = litIn(key)
141 hover = at === null ? null : { key, at: pixel(at) }
142 if (!sameRect(before, litIn(key))) $.ui.invalidate('ui.render')
143}
144
145/** The strip as last drawn, for the orbs' ripple to repaint: its size and the boxes over it; none without the strip. */
146let stripShown: { columns: number; rows: number; boxes: readonly OverlayBox[] } | null = null
147/** The orbs' ripple as last painted: the strip's alone, the dash's rows over the map lying still. */
148let ripple = 0
149
150/** The strip as Raster cells, the orbs at the ripple's phase, what the pointer is over lit. */
151const stripCells = (g: Game, width: number, boxes: readonly OverlayBox[]) => {
152 const c = stripPixels(g, width, boxes, ripple)
153 return canvasCells({ ...c, px: litWindow('hud', c.px, stripHot(g, width, boxes), width, false) })
154}
155
156/** A window's clickable places with its close box (the strip has none); and its pixels with the one under the pointer lit. */
157const litWindow = (key: string, px: string[][], hot: readonly Rect[], width = INV_W, hasClose = true) => {
158 const [x, y, w, h] = closeBoxOf(width)
159 hotOf.set(key, hasClose ? [...hot, { x, y, w, h }] : hot)
160 const lit = litIn(key)
161 return lit === undefined ? px : lightUp(px, lit)
162}
163/** The pixel map's mounted size, which a repaint must match. */
164let mapSize: { columns: number; rows: number } | null = null
165/** Where the camera looks, in world pixels, and in which place: it holds still while the hero stays mid-view. */
166let camera: { at: Pos; place: string } | null = null
167/** Whether the pane last drew the pixel dashboard, with no log under it: messages then come as toasts. */
168let isPixelDash = false
169
170/** The place the hero is in: the town, or a depth of the dungeon. */
171const placeOf = (g: Game) => (g.where === 'town' ? 'town' : `depth ${g.depth}`)
172
173/** The camera for this frame: recentered in a new place, else following the hero only out of the view's middle, the
174 * middle of what the dash leaves seen. */
175const cameraFor = (g: Game, hero: Pos, columns: number, rows: number) => {
176 const at = tileCamera(camera === null || camera.place !== placeOf(g) ? null : camera.at, hero, columns, rows * 2 - mapCovered(columns, rows))
177 camera = { at, place: placeOf(g) }
178 return at
179}
180/** The pane's width, in columns, from which it draws the pixel strip. */
181const HUD_COLUMNS = 80
182
183/** What a pane too narrow for the pixel views is told: which need more room, and how much. */
184export const widthNote = (columns: number) => {
185 if (columns < HUD_COLUMNS) return `Widen the pane for the pixel strip and inventory (${HUD_COLUMNS} columns) and the full windows (${INV_W}); it is ${columns}.`
186 if (columns < INV_W) return `Widen the pane for the full pixel windows, the town's and the character sheet (${INV_W} columns); it is ${columns}.`
187 return undefined
188}
189
190/** The classes a new game can be, in the order the sheet offers them. */
191const CLASSES: readonly HeroClass[] = ['knight', 'mage', 'rogue']
192
193const tileCenter = (g: Game): Pos => ({ x: g.x * TILE_PX + TILE_PX / 2, y: g.y * TILE_PX + TILE_PX / 2 })
194
195/** The character sheet's rows: its title, a row per attribute, a blank, the new-game row and a line per class. */
196const SHEET_ROWS = 3 + ATTRIBUTES.length
197/** The rows a panel under the dashboard takes: its title, a row per ware or worn slot, and its close row. */
198/** The rows the text stash keeps for its list. */
199const TEXT_STASH_ROWS = 10
200const PANEL_ROWS: Partial<Record<Game['panel'], number>> = { sheet: SHEET_ROWS, shop: 3 + WARES.length, forge: 2 + SLOTS.length, stash: 3 + TEXT_STASH_ROWS, healer: 3, elder: 5, mystic: 4, guard: 3, villager: 3, dog: 4 }
201
202/** The how-it-plays window's rows as text: its head, a line a rule, where things are, the close key. */
203const HELP_TEXT_ROWS = HELP_ROWS.length + 3
204/** The log as text: its head, every line kept, and Close. */
205const LOG_TEXT_ROWS = LOG_KEPT + 3
206
207/** The townsfolk without work, by window: who they are, as their window's title says. */
208const TALKER_NAME: Readonly<Record<'guard' | 'villager' | 'dog', string>> = { guard: 'The guard', villager: 'A villager', dog: 'The dog' }
209
210/** The undo key's label: what it would buy back and for how much, or that there is nothing. */
211const undoLabel = (g: Game) => {
212 const sold = g.lastSold
213 if (sold === null) return 'Undo sale'
214 const what = sold.items.length === 1 ? sold.items[0]!.name : `${sold.items.length} items`
215 return `Undo sale: ${what} (${sold.coins} gold)`
216}
217
218const VIEW_LABEL: Readonly<Record<Game['zoom'], string>> = { 1: 'View: map', 2: 'View: ASCII', 3: 'View: pixel' }
219
220const stopWalk = () => {
221 // A walk cut short takes its footsteps with it; one played out (walk already null) leaves its last sounds to finish.
222 if (walk !== null) walkMix?.stop()
223 walkMix = null
224 ticker.stop('walk')
225 walk = null
226}
227
228/**
229 * The game as drawn at a frame of the walk: as of the tile the drawn hero has reached (`walkState`), for map, strip
230 * and log alike; a walk with no tiles of its own (the bench's) hides only what lies ahead of him.
231 */
232const drawnAt = (g: Game, frame: number) => {
233 if (walk === null) return g
234 return walk.tiles !== null ? walkState(walk.start, walk.tiles, frame) : walkingGame(g, walk.start, walk.path, frame)
235}
236
237
238/** Where the hero is drawn now and the game as drawn, while a walk plays; the hero undefined once it is over. */
239const walkingAt = (g: Game) => {
240 if (walk === null) return { heroAt: undefined, drawn: g }
241 const at = walkPosition(walk.path, walk.frame)
242 // Mid-walk the map is the walk's own game, as its frames draw it: the stored one has not taken the walk yet.
243 return at.isDone ? { heroAt: undefined, drawn: g } : { heroAt: at, drawn: drawnAt(walk.game, walk.frame) }
244}
245
246/** One frame of the walk, repainted in place; the last one hands back to the ordinary drawing. Answers whether it goes on. */
247/**
248 * The map's cells last sent to the terminal, to leave out a frame that would show the same: a blow's number rises a
249 * pixel every other frame and its first frame is held, so about half of a blow's frames repeat the one before.
250 * Cleared whenever the pane draws the map itself.
251 */
252let lastMap: string | null = null
253/** Sends the map's cells, unless they are what the terminal already shows; a send left out counts as painted. */
254export const sendMap = async ($: EngineInterface, cells: string, size: { columns: number; rows: number }) => {
255 if (cells === lastMap) {
256 unsent += 1
257 return { deny: undefined }
258 }
259 const painted = await $.ui.blit({ requestId: PANE, key: 'map', cells, ...size }).catch(() => ({ deny: 'failed' }))
260 lastMap = painted.deny === undefined ? cells : null
261 return painted
262}
263/** Frames left out as repeats, for the frames report. */
264let unsent = 0
265/** Whether the walk playing is the bench's, whose frames are stamped with their number. */
266let isBenching = false
267/** The map as the pane itself draws it: what the terminal shows from then on. */
268const drawnMap = (cells: string) => {
269 lastMap = cells
270 return cells
271}
272
273/**
274 * The walk's frames go to the host without waiting for it to take each: a host busy (drawing the chat beside the pane)
275 * would otherwise hold every tick and slow the hero. One blit is in flight at a time; a frame drawn meanwhile waits in
276 * its place, a newer one replacing it, so the host always gets the latest and the walk keeps its pace.
277 */
278let mapInFlight: Promise<void> | null = null
279let mapWaiting: { cells: string; size: { columns: number; rows: number } } | null = null
280/** A walk frame the host refused: the walk gives way to the ordinary drawing on its next frame. */
281let isMapRefused = false
282const sendLatest = ($: EngineInterface, cells: string, size: { columns: number; rows: number }) => {
283 if (mapInFlight !== null) {
284 mapWaiting = { cells, size }
285 return
286 }
287 const t0 = fineNow()
288 mapInFlight = sendMap($, cells, size).then(painted => {
289 repaints.add(fineNow() - t0)
290 mapInFlight = null
291 if (painted.deny !== undefined) isMapRefused = true
292 const next = mapWaiting
293 mapWaiting = null
294 if (next !== null && !isMapRefused) sendLatest($, next.cells, next.size)
295 })
296}
297/** Lets the walk's frames still on their way land before anything else draws the map; a waiting one is dropped. */
298const drainMap = async () => {
299 mapWaiting = null
300 while (mapInFlight !== null) await mapInFlight
301}
302
303/** A tile reached: what happened on it shows now, the strip and log redrawn, its toast and (walk not mixed) its sound played. */
304const tellTile = ($: EngineInterface, w: Walk, frame: number) => {
305 const reached = frame % FRAMES_PER_TILE === 0 ? w.tiles?.[frame / FRAMES_PER_TILE - 1] : undefined
306 if (reached === undefined || reached === w.game) return
307 const shown = w.shown ?? w.start
308 const { toast, sound } = tellOnTheWay(w, reached, pending?.byHand ?? false, !isPixelDash || reached.panel === 'bag')
309 w.shown = reached
310 if (toast !== null) $.ui.toast(toast)
311 if (soundSettings.effects && sound !== undefined && !w.isMixed) void playEffect($, sound)
312 if (reached.log !== shown.log) $.ui.invalidate('ui.render')
313}
314
315/**
316 * The walk over, or a repaint refused (the pane closed, its size changed): the click it was walking for carried out,
317 * a blow struck meanwhile shown with its sounds, the ordinary drawing taking over, and a click that waited for it gone.
318 */
319const endWalk = async ($: EngineInterface, w: Walk) => {
320 if (walk === w) walk = null
321 isBenching = false
322 await drainMap()
323 isMapRefused = false
324 await settle($)
325 const after = hitsAfterWalk
326 hitsAfterWalk = null
327 if (after !== null && walk === null) {
328 void playSounds($, after.sounds)
329 playHits($, after.g, after.hits)
330 }
331 $.ui.invalidate('ui.render')
332 const waiting = queued
333 queued = null
334 if (waiting !== null) await act($, waiting.action, waiting.byHand)
335 return false
336}
337
338/** One frame of the walk drawn and sent, timed; the camera following the game as drawn (a walk to the portal is drawn in town, though it ends in the dungeon). */
339const drawWalk = ($: EngineInterface, w: Walk, frame: number, at: ReturnType<typeof walkPosition>, size: { columns: number; rows: number }) => {
340 const drawnGame = drawnAt(w.game, frame)
341 const view = cameraFor(drawnGame, at, size.columns, size.rows)
342 const builds = layerBuildCount()
343 const drawFrom = fineNow()
344 const drawn = pixelMap(drawnGame, size.columns, size.rows, at, view, [], 0, isStrideFrame(frame), STILL, undefined, stripOverMap(drawnGame, size.columns, 0), mapCovered(size.columns, size.rows))
345 ;(layerBuildCount() === builds ? keptMaps : builtMaps).add(fineNow() - drawFrom)
346 // The bench walk's frames carry their number, so a measure reads which arrived: skipped, repeated, late.
347 sendLatest($, isBenching ? stampCells(drawn, frame) : drawn, size)
348}
349
350const walkFrame = async ($: EngineInterface, frame: number) => {
351 const w = walk
352 if (w === null || mapSize === null) return false
353 w.frame = frame
354 const at = walkPosition(w.path, frame)
355 tellTile($, w, frame)
356 const step = soundSettings.effects && !at.isDone && !w.isMixed ? stepAt(frame) : undefined
357 if (step !== undefined) void playEffect($, step)
358 if (at.isDone || isMapRefused) return endWalk($, w)
359 drawWalk($, w, frame, at, mapSize)
360 return true
361}
362
363const startWalk = ($: EngineInterface, path: Pos[], start: Game, game: Game, tiles: Game[] | null = null) => {
364 if (walk !== null) walkMix?.stop()
365 walkMix = null
366 walk = { path, frame: 0, start, game, tiles, shown: null, isMixed: false }
367 const mix = soundSettings.effects && !isPaused ? walkClip(start, game, path.length - 1, tiles) : undefined
368 if (mix !== undefined && players.length < placesNow()) {
369 walk.isMixed = true
370 walkMix = startPlayer($, { clip: { base64: mix, mime: 'audio/wav' }, label: 'walk', gain: 1 }, 0)
371 }
372 ticker.start('walk', frame => walkFrame($, frame), () => $.clock.every(FRAME_MS, () => void $.clock.now().then(ticker.tick)))
373}
374
375const ms = (n: number) => (n < 1 ? n.toFixed(2) : n.toFixed(1))
376/**
377 * The measure in words: how evenly ticks come, how long a tick takes, how much of that is the host taking the
378 * repainted map, how long a whole-pane redraw takes, and how long a walk frame's map takes to draw with its ground layer
379 * kept and with it drawn afresh.
380 */
381export const framesReport = (s: TickStats, repaint: FrameSummary, redraw: FrameSummary, kept: FrameSummary = summarize([]), built: FrameSummary = summarize([])) =>
382 s.ticks === 0
383 ? 'No frames played yet: walk somewhere first.'
384 : `${s.ticks} ticks, ${s.late} late (over ${ms(LATE_MS)} ms after the last), ${s.skipped} skipped while a frame was drawing. ` +
385 `Gap between ticks: p50 ${ms(s.interval.p50)}, p99 ${ms(s.interval.p99)}, max ${ms(s.interval.max)} ms. ` +
386 `Drawing a tick: p50 ${ms(s.work.p50)}, p99 ${ms(s.work.p99)}, max ${ms(s.work.max)} ms (budget ${FRAME_MS}), ` +
387 `of which the host taking the map: p50 ${ms(repaint.p50)}, p99 ${ms(repaint.p99)}, max ${ms(repaint.max)} ms. ` +
388 `Whole-pane redraws: ${redraw.count}, p50 ${ms(redraw.p50)}, p99 ${ms(redraw.p99)}, max ${ms(redraw.max)} ms. ` +
389 `Drawing the map: ${kept.count} with the ground kept, p50 ${ms(kept.p50)}, p99 ${ms(kept.p99)} ms; ${built.count} drawn afresh, p50 ${ms(built.p50)}, p99 ${ms(built.p99)}, max ${ms(built.max)} ms.`
390
391/**
392 * What to toast after an action: the game's own news first; with no log on screen, also what a click of the player's
393 * came to, and a new foe with its threat.
394 */
395export const toastFor = (before: Game, next: Game, byHand: boolean, hasLog: boolean): string | null => {
396 if (next.toast !== null) return next.toast
397 if (hasLog) return null
398 if (byHand && next.fresh > 0) return next.log.at(-1) ?? null
399 const foe = next.foe
400 if (foe !== null && (before.foe?.name !== foe.name || before.foe.maxHp !== foe.maxHp)) return `A ${foe.name} emerges: ${threat(next)}.`
401 return null
402}
403
404/** A click the hero is walking to carry out: the rules take it when the walk ends, not when it is made. */
405let pending: { action: Action; byHand: boolean; isOnlyAWalk?: boolean } | null = null
406
407/**
408 * A walk that ends by changing place, walked where it began and only its end elsewhere: through town to the portal,
409 * or to the stairs and down them.
410 */
411const isWalkThrough = (before: Game, next: Game) =>
412 (before.where === 'town' && next.where === 'dungeon') ||
413 (before.where === 'dungeon' && next.where === 'dungeon' && next.depth === before.depth + 1 && same(next.walked.at(-1) ?? before, levelOf(before).stairs))
414/** Whether a walk from `before` to `next` plays on screen: a pixel map, and the hero still in the same place, or a walk through. */
415const isPlayed = (before: Game, next: Game) => next.walked.length > 0 && (placeOf(next) === placeOf(before) || isWalkThrough(before, next)) && (next.zoom >= 3 || isPixelDash)
416
417/**
418 * What is new in `next` since `shown`, a state the same action's walk already showed: only the log lines after it and
419 * a toast it had not; `next` as it is when nothing was shown.
420 */
421const since = (shown: Game | null, next: Game): Game =>
422 shown === null ? next : { ...next, fresh: Math.max(0, next.fresh - shown.fresh), toast: next.toast === shown.toast ? null : next.toast }
423
424/**
425 * The toast and the sound for a step of the game: `next` against `from`, the game last shown, telling only what `next`
426 * adds to `shown` when the same action's walk already showed some of it.
427 */
428export const announce = (shown: Game | null, from: Game, next: Game, byHand: boolean, hasLog: boolean, hits: readonly Hit[]) => {
429 const told = since(shown, next)
430 const sounds = soundsFor(from, told, hits)
431 return { toast: toastFor(from, told, byHand, hasLog), sound: sounds[0], sounds }
432}
433
434/** What a walk tells on reaching a tile: what is new against the tile it last told, or its start before the first. */
435export const tellOnTheWay = (walked: { start: Game; shown: Game | null }, reached: Game, byHand: boolean, hasLog: boolean) =>
436 announce(walked.shown, walked.shown ?? walked.start, reached, byHand, hasLog, [])
437
438/** What an action tells once carried out: what its walk did not already tell on the way (`shown`, its last tile). */
439export const tellAtTheEnd = (shown: Game | undefined, before: Game, next: Game, byHand: boolean, hasLog: boolean, hits: readonly Hit[]) =>
440 announce(shown ?? null, shown ?? before, next, byHand, hasLog, hits)
441
442/** Applies an action now; `isWalkShown` when its walk has already played, so it is not played again. */
443/**
444 * The walk on screen after an action: one it walked played, unless already shown; one it walked unseen stopped; else
445 * the walk under way keeps drawing the click's plan, what happened to the foe (a tool call striking it) shown at once.
446 */
447const followWalk = ($: EngineInterface, before: Game, next: Game, isWalkShown: boolean) => {
448 if (!isWalkShown && isPlayed(before, next)) startWalk($, [{ x: before.x, y: before.y }, ...next.walked], before, next)
449 else if (next.walked.length > 0) stopWalk()
450 else if (walk !== null) walk = { ...walk, start: { ...walk.start, foe: next.foe }, game: pending === null ? next : walk.game }
451}
452
453/** The saves under way, one after another, so a later game never lands before an earlier one. */
454let saving: Promise<unknown> = Promise.resolve()
455
456/** Whether the store holds a save from a newer version of the mod: it is left as it is, the game played unsaved. */
457let isSaveHeld = false
458
459/** Keeps the game across sessions as it stands after every change: what is done by hand between turns is not lost. */
460const save = ($: EngineInterface, g: Game) => {
461 if (isSaveHeld) return
462 saving = saving.then(() => $.store.set(SAVE, savable(g))).catch(() => undefined)
463}
464
465const commit = async ($: EngineInterface, action: Action, byHand: boolean, isWalkShown: boolean, shown?: Game) => {
466 const before = normalize(await read($, game))
467 const next = normalize(await update($, game, g => apply(g, action)))
468 if (next !== before) save($, next)
469 followWalk($, before, next, isWalkShown)
470 const hits = hitsBetween(before, next)
471 // A death plays on the level the hero fell on, not the one they woke on.
472 const scene = deathScene(before, next) ?? next
473 // What the walk already showed on its way is not told again.
474 const { toast, sounds } = tellAtTheEnd(shown, before, next, byHand, !isPixelDash || next.panel === 'bag', hits)
475 if (toast !== null) $.ui.toast(toast)
476 syncMusic($, next)
477 const isDrawn = hits.length > 0 && isPixelDash && mapSize !== null
478 // A blow struck mid-walk is drawn when the walk ends: its sounds wait for it, so they come with its flash.
479 if (!isDrawn || walk === null) await playSounds($, sounds)
480 if (!isDrawn) return
481 // A fall waiting on a walk still holds the death window back.
482 if (hits.some(h => h.kind === 'death')) isFalling = true
483 if (walk === null) playHits($, scene, hits)
484 else hitsAfterWalk = { g: scene, hits, sounds }
485}
486
487/** Blows landed while a walk plays, shown once it ends. */
488let hitsAfterWalk: { g: Game; hits: readonly Hit[]; sounds: readonly Sound[] } | null = null
489
490/**
491 * A blow played out on the map: the struck foe flashing, then the numbers rising over 18 frames, a frame a tick;
492 * a walk starting meanwhile takes the map over, and the ordinary drawing takes over at the end.
493 */
494const playHits = ($: EngineInterface, g: Game, hits: readonly Hit[]) => {
495 isHitPlaying = true
496 isFalling = hits.some(h => h.kind === 'death')
497 ticker.start('hits', async frame => {
498 if (walk !== null || mapSize === null || frame >= hitFramesFor(hits)) {
499 isHitPlaying = false
500 isFalling = false
501 if (frame >= hitFramesFor(hits)) await endHits($)
502 return false
503 }
504 const view = cameraFor(g, tileCenter(g), mapSize.columns, mapSize.rows)
505 const painted = await sendMap($, pixelMap(g, mapSize.columns, mapSize.rows, undefined, view, hits, frame, false, STILL, undefined, stripOverMap(g, mapSize.columns, 0), mapCovered(mapSize.columns, mapSize.rows)), mapSize)
506 if (painted.deny === undefined) return true
507 // A map the host will not take (a window over it, the pane resized) ends the blow: the idle motion and the death
508 // window wait on it no longer.
509 isHitPlaying = false
510 isFalling = false
511 $.ui.invalidate('ui.render')
512 return false
513 }, () => $.clock.every(FRAME_MS, () => void $.clock.now().then(ticker.tick)))
514}
515
516/**
517 * A blow's frames over: the ordinary map sent as the pane now draws it, then the pane redrawn. The pane's own map may
518 * already hold those cells from before the blow, and a host need not repaint a map whose cells it already had, so
519 * without the frame the terminal would keep the blow's last one, its number still up.
520 */
521const endHits = async ($: EngineInterface) => {
522 const size = mapSize
523 if (size !== null) {
524 const g = normalize(await read($, game))
525 const view = cameraFor(g, tileCenter(g), size.columns, size.rows)
526 await sendMap($, pixelMap(g, size.columns, size.rows, undefined, view, [], 0, false, shown, mapHoverOf(g).lit, stripOverMap(g, size.columns, 0), mapCovered(size.columns, size.rows)), size)
527 }
528 $.ui.invalidate('ui.render')
529}
530
531/** Carries out the click the hero is walking to, now: at the walk's end, or cut short by what cannot wait. */
532/** A click that came during a charge, carried out once the charge's walk is over. */
533let queued: { action: Action; byHand: boolean } | null = null
534
535/**
536 * Makes way for a new action that moves the hero, the drawn hero being the truth: a walk under way that is only a walk
537 * is cut where he is drawn, the rules walking him only the tiles he has visibly walked, so nothing ahead is taken. One
538 * that ends in more (a blow, a visit, the stairs) is not cut, its end being the point: a tool call carries it out, a
539 * click waits for it. Answers whether the new action has to wait.
540 */
541const interrupt = async ($: EngineInterface, isClick: boolean): Promise<boolean> => {
542 const p = pending
543 const w = walk
544 if (p === null) return false
545 const reached = w === null ? 0 : Math.min(Math.floor(w.frame / FRAMES_PER_TILE), w.path.length - 1)
546 const isOver = w === null || reached >= w.path.length - 1
547 const isCuttable = p.isOnlyAWalk === true
548 if (isOver || !isCuttable || w === null) {
549 if (isClick && !isOver) return true
550 await settle($)
551 return false
552 }
553 pending = null
554 const shown = w.shown ?? undefined
555 stopWalk()
556 if (reached > 0) await commit($, { kind: 'trail', path: w.path.slice(1, reached + 1) }, p.byHand, true, shown)
557 return false
558}
559
560const settle = async ($: EngineInterface) => {
561 const p = pending
562 if (p === null) return
563 pending = null
564 const shown = walk?.shown ?? undefined
565 stopWalk()
566 await commit($, p.action, p.byHand, true, shown)
567}
568
569/**
570 * Applies an action; `byHand` when the player's own click or press made it. A click that walks plays its walk first,
571 * from the game as it is, and is carried out on arrival: the blow, the visit, the pickups land when the hero gets
572 * there. A new click, or anything that would move the hero, carries out a click still walking first; anything else
573 * happens alongside the walk.
574 */
575/** How long the hero stands with nothing happening before the idle bob starts. */
576export const IDLE_BEFORE_BOB_MS = 10_000
577/** The bob's phases and the shimmer's, each held this many frames. */
578const BOB_FRAMES = 12
579const SHIMMER_FRAMES = 10
580
581/** The idle motion at `now`: the bob once idle since `lastActive` long enough, lifted first; the shimmer while in town. */
582export const ambientAt = (now: number, lastActive: number, isTown: boolean): Ambient => {
583 const idle = now - lastActive - IDLE_BEFORE_BOB_MS
584 return { bob: idle >= 0 && Math.floor(idle / (BOB_FRAMES * FRAME_MS)) % 2 === 0, shimmer: isTown && Math.floor(now / (SHIMMER_FRAMES * FRAME_MS)) % 2 === 1 }
585}
586
587/** When something last happened (an action, a walk or a blow playing), and the idle motion last painted. */
588let lastActive = 0
589let shown: Ambient = STILL
590let isHitPlaying = false
591/** The hero's fall is playing: the death window waits for it to end. */
592let isFalling = false
593
594/** Paints the idle motion when a phase flips, on a slow timer of its own; walks and blows keep it waiting. */
595const ambientTick = async ($: EngineInterface) => {
596 if (mapSize === null) return
597 const now = await $.clock.now()
598 if (walk !== null || isHitPlaying) {
599 lastActive = now
600 return
601 }
602 const g = normalize(await read($, game))
603 rippleTick($, g, now)
604 const next = ambientAt(now, lastActive, g.where === 'town')
605 if (next.bob === shown.bob && next.shimmer === shown.shimmer) return
606 shown = next
607 const size = mapSize
608 const view = cameraFor(g, tileCenter(g), size.columns, size.rows)
609 await sendMap($, pixelMap(g, size.columns, size.rows, undefined, view, [], 0, false, shown, mapHoverOf(g).lit, stripOverMap(g, size.columns, 0), mapCovered(size.columns, size.rows)), size)
610}
611
612/** A ripple's repaint on its way to the host: one at a time, so a slow host is never sent a queue of them; and the
613 * strip it last sent, forgotten when the pane draws the strip itself. */
614let isRippling = false
615let lastRipple: string | null = null
616
617/** Repaints the strip when the orbs' ripple moves on a phase: the strip alone, its numbers still where they were, sent
618 * without holding up the idle timer. A strip the host will not take (a window over it, the pane resized) holds still
619 * till the pane draws it again. */
620const rippleTick = ($: EngineInterface, g: Game, now: number) => {
621 const strip = stripShown
622 if (strip === null || isRippling || !isDash(strip.columns) || orbPhase(now) === ripple) return
623 ripple = orbPhase(now)
624 // A ripple that moves nothing on the strip (both surfaces over the map, or none) sends nothing.
625 const cells = stripCells(g, strip.columns, strip.boxes)
626 if (cells === lastRipple) return
627 lastRipple = cells
628 isRippling = true
629 void $.ui.blit({ requestId: PANE, key: 'hud', cells, columns: strip.columns, rows: strip.rows })
630 .catch(() => ({ deny: 'failed' }))
631 .then(painted => {
632 if (painted.deny !== undefined) stripShown = null
633 })
634 .finally(() => {
635 isRippling = false
636 })
637}
638
639/** The time, for the idle motion's bookkeeping; a host without a clock leaves the last known time standing. */
640const nowOr = ($: EngineInterface, known: number) => $.clock.now().catch(() => known)
641
642/** The idle motion's timer, started once, by the session's start or the first action, whichever comes first. */
643let ambientTimer: { cancel: () => void } | null = null
644const startAmbient = ($: EngineInterface) => {
645 ambientTimer ??= $.clock.every(2 * FRAME_MS, () => void ambientTick($).catch(() => undefined))
646}
647
648const act = async ($: EngineInterface, action: Action, byHand = false) => {
649 // Paused, the coding goes on without the game: nothing it does moves the hero.
650 if (isPaused) return
651 // A click or a key is a look at the pane: when enough happened unseen, the recap opens instead of what was asked.
652 if (byHand && (await isRecapShown($))) return
653 void loadClips($)
654 lastActive = await nowOr($, lastActive)
655 startAmbient($)
656 shown = { ...shown, bob: false }
657 const before = normalize(await read($, game))
658 const planned = apply(before, action)
659 const moves = action.kind === 'goto' || planned.x !== before.x || planned.y !== before.y || placeOf(planned) !== placeOf(before)
660 // A click that comes during a charge into a blow waits for the blow, then goes.
661 if (moves && (await interrupt($, byHand && action.kind === 'goto'))) {
662 queued = { action, byHand }
663 return
664 }
665 // Any action that walks (a click, an explore, a charge at a foe) is carried out when the hero gets there, so what
666 // lies on the way is picked up when he reaches it, not when the walk begins.
667 const now = normalize(await read($, game))
668 const plan = apply(now, action)
669 // Only while the map is on screen: a walk nobody can see would hold the action back for nothing.
670 if (isPlayed(now, plan) && mapSize !== null) {
671 pending = { action, byHand, isOnlyAWalk: isOnlyAWalk(now, plan) }
672 startWalk($, [{ x: now.x, y: now.y }, ...plan.walked], now, plan, alongTheWay(now, plan))
673 return
674 }
675 await commit($, action, byHand, false)
676}
677
678const targetOf = (e: ToolCallInput) => {
679 if (e.tool === 'Bash') return e.command
680 if (e.tool === 'Edit' || e.tool === 'Write') return e.file_path
681 if (e.tool === 'NotebookEdit') return e.notebook_path
682 return undefined
683}
684
685/** The work a successful shell call came to: the git work the host reports on its record, or a test run. */
686const workDone = (e: ToolCallInput, result: unknown) =>
687 e.tool === 'Bash' ? workOf(e.command, (result as { gitOperation?: GitOperation } | undefined)?.gitOperation) : undefined
688
689/** The sound as set in settings, started from the `sound` and `music` options, each on unless turned off. */
690let soundSettings: SoundSettings = { effects: true, music: true, volume: VOLUME_STEPS }
691
692/**
693 * Whether the game is paused: the pane closed and not drawn since. Paused, nothing moves it (tool calls, prompts and
694 * turns pass it by), no timer runs, nothing sounds; drawing the pane again takes it up where it stood.
695 */
696let isPaused = false
697
698/** What the pane closing does: the game paused, its tune and effect stopped, its walk, blow and idle motion too. */
699/** Taken up again where it stood, the pane being in front of the player: drawn, opened by /dungeon, or clicked. */
700const resume = async ($: EngineInterface) => {
701 if (!isPaused) return
702 isPaused = false
703 startAmbient($)
704 syncMusic($, normalize(await read($, game)))
705 $.ui.invalidate('ui.render')
706}
707
708/** Whether closing the pane `closed` leaves the game unseen: its pane, with none of its panes still open. */
709export const isLeftUnseen = (closed: string, open: readonly { id: string }[]) => closed === PANE && !open.some(p => p.id === PANE)
710
711const pause = async ($: EngineInterface) => {
712 isPaused = true
713 // A walk under way ends as a new action would end it: cut where the hero is drawn, or carried out; nothing it held
714 // back (a click that waited for it, its blows) is left to go off once the pane is drawn again.
715 await interrupt($, false).catch(() => undefined)
716 queued = null
717 hitsAfterWalk = null
718 music?.stop()
719 music = null
720 for (const p of players) p.stop()
721 players = []
722 waiting = []
723 stopWalk()
724 ticker.stop('hits')
725 isHitPlaying = false
726 isFalling = false
727 ambientTimer?.cancel()
728 ambientTimer = null
729 stripShown = null
730}
731
732/** A clip to play: the game's own or one it mixed, its name, and its gain in the mix. */
733type Play = { clip: { asset: string } | { base64: string; mime: string }; label: string; gain: number }
734/** The clips going, each with its stop; and those waiting for a place, oldest first. */
735let players: { sound: string; at: number; stop: () => void }[] = []
736let waiting: Play[] = []
737const placesNow = () => HOST_PLACES - rounds
738
739/** Plays an effect over whatever is sounding, or once a place comes free (`mixFor`); a footstep with none to spare is let go. */
740const playEffect = async ($: EngineInterface, clip: Sound) => {
741 if (isPaused) return
742 const at = await nowOr($, lastActive)
743 const fate = mixFor(players.length, placesNow(), clip)
744 const play: Play = { clip: { asset: soundAsset(clip) }, label: clip, gain: gainOf(clip) }
745 if (fate === 'wait') waiting.push(play)
746 else if (fate === 'play') startPlayer($, play, at)
747}
748
749/**
750 * An action's sounds: one alone as its own clip; several mixed into one, each on its frame (`cuesFor`): the deed, the
751 * answer, the aftermath, the fanfare. While the clips are not loaded yet, the greatest alone.
752 */
753const playSounds = async ($: EngineInterface, sounds: readonly Sound[]) => {
754 if (!soundSettings.effects || isPaused || sounds.length === 0) return
755 const pcm = sounds.length > 1 && clips !== null ? mixdown(cuesFor(sounds), clips) : undefined
756 if (pcm === undefined || pcm.length === 0) return playEffect($, sounds[0]!)
757 const at = await nowOr($, lastActive)
758 const play: Play = { clip: { base64: base64Of(wavOf(pcm)), mime: 'audio/wav' }, label: sounds.join('+'), gain: 1 }
759 if (players.length < placesNow()) startPlayer($, play, at)
760 else waiting.push(play)
761}
762
763/**
764 * Plays a clip, the game's own or one it mixed, at `gain` in the mix and the volume set. Never stopped from here but
765 * by a walk cut short or the pane closing: afplay plays the clip through to the speaker and only then exits, and
766 * stopping it sooner loses what is still on its way. It holds its place with the host until then, as the host counts
767 * it; then a waiting effect goes.
768 */
769const startPlayer = ($: EngineInterface, play: Play, at: number) => {
770 const sounding = new AbortController()
771 const player = { sound: play.label, at, stop: () => sounding.abort() }
772 players.push(player)
773 void $.audio.play(play.clip, { signal: sounding.signal, gain: play.gain * volumeShare(soundSettings) })
774 .catch(() => undefined)
775 .finally(() => {
776 players = players.filter(p => p !== player)
777 const next = waiting.shift()
778 if (next !== undefined) void nowOr($, at).then(now => startPlayer($, next, now))
779 })
780 return player
781}
782
783/** The game's clips as samples, for its own mixes: read once from its sounds, in the background. */
784let clips: Map<Sound, Int16Array> | null = null
785let clipsLoading = false
786const loadClips = async ($: EngineInterface) => {
787 if (clips !== null || clipsLoading) return
788 clipsLoading = true
789 try {
790 const loaded = new Map<Sound, Int16Array>()
791 for (const sound of Object.keys(CLIP_MS) as Sound[]) {
792 const pcm = readWav(bytesOf((await $.fs.read(`${$.plugin.root}/${soundAsset(sound)}`, { as: 'bytes' })).base64))
793 if (pcm !== undefined) loaded.set(sound, pcm)
794 }
795 clips = loaded
796 } catch {
797 // Unread, the game sounds clip by clip.
798 } finally {
799 clipsLoading = false
800 }
801}
802
803/**
804 * A walk's own clip, as base64 WAV: its footsteps and what each tile it reaches comes to, each on its frame; none
805 * while the clips are not loaded yet, when the walk sounds clip by clip as before.
806 */
807const walkClip = (start: Game, game: Game, tileCount: number, tiles: Game[] | null) => {
808 if (clips === null) return undefined
809 const tileSounds = (tiles ?? []).map((reached, i) => (reached === game ? [] : announce(i === 0 ? null : tiles![i - 1]!, i === 0 ? start : tiles![i - 1]!, reached, false, true, []).sounds))
810 const pcm = mixdown(walkCues(tileCount, tileSounds), clips)
811 return pcm.length === 0 ? undefined : base64Of(wavOf(pcm))
812}
813
814/** Sets the sound; music turned on or off, or a new volume, restarts the tune at its level. */
815const setSound = ($: EngineInterface, g: Game, next: SoundSettings) => {
816 const isTuneChanged = next.music !== soundSettings.music || next.volume !== soundSettings.volume
817 soundSettings = next
818 void $.store.set(SOUND, next).catch(() => undefined)
819 if (!isTuneChanged) return
820 music?.stop()
821 music = null
822 syncMusic($, g)
823}
824
825/** The tune's rounds going (each holds one of the host's places), the tune playing now, and how to stop it. */
826let rounds = 0
827let music: { stop: () => void } | null = null
828
829/** Starts the town's tune on coming into town and stops it on leaving; the host loops it until stopped. */
830const syncMusic = ($: EngineInterface, g: Game) => {
831 const tune = isPaused ? undefined : musicFor(g, soundSettings.music)
832 if (tune !== undefined && music === null) {
833 // Each round started on time by the pane: the host's own loop waits for afplay to exit, about a second after the
834 // tune ends, so it fell silent that long every round. The round before is stopped once its last notes have sounded.
835 // A round left to play out: it holds its place until afplay exits, a second or so after the next has begun.
836 let playing = new AbortController()
837 const round = () => {
838 rounds += 1
839 void $.audio.play({ asset: tune }, { signal: playing.signal, gain: MUSIC_GAIN * volumeShare(soundSettings) })
840 .catch(() => undefined)
841 .finally(() => (rounds -= 1))
842 }
843 round()
844 const timer = $.clock.every(TUNE_MS, () => {
845 playing = new AbortController()
846 round()
847 })
848 music = {
849 stop: () => {
850 timer.cancel()
851 playing.abort()
852 },
853 }
854 } else if (tune === undefined && music !== null) {
855 music.stop()
856 music = null
857 }
858}
859
860/** Each action in turn, as the player's own. */
861/** Looks at the pane, when tool calls have been counted since the last look: whether the recap opened. */
862const isRecapShown = async ($: EngineInterface) => {
863 const g = await current($)
864 if (g.recap === undefined || g.panel === 'recap') return false
865 await act($, { kind: 'look' })
866 return (await current($)).panel === 'recap'
867}
868
869const actAll = async ($: EngineInterface, actions: readonly Action[]) => {
870 for (const action of actions) await act($, action, true)
871}
872const current = async ($: EngineInterface) => normalize(await read($, game))
873
874/** Each click layer's gestures, to its window: what the window makes of a gesture, its own state kept, and the pane
875 * redrawn where the window's look depends on that state. A click on the pixel map walks the hero to the tile under it;
876 * one on the strip opens the inventory or uses a belt slot. */
877/** The names the map's click layer writes over a tile, in its cells: the tile they lie on, each row's item (none on the
878 * row counting the rest), the row the pointer is on. */
879type MapLabel = { x: number; y: number; tile: Pos; lines: MapLabelLine[]; row?: number }
880/** A row of it: a mark (an upgrade's arrow, or room for one), the words, their color, the item's id; or the row that
881 * turns the page. */
882type MapLabelLine = { mark: string; text: string; color: string; id?: number; turnsPage?: true }
883/** Where the pointer rests over the map, in its cells, with the map's size; null off it. */
884let mapPointer: { x: number; y: number; columns: number; rows: number } | null = null
885/** The tile whose label the pointer moved onto from it: the label stays while the pointer is on it, though the tiles
886 * under it are others, so a row can be chosen. */
887let pinned: Pos | null = null
888/** The page the pinned tile's pile is on; back to the first on another tile. */
889let labelPage = 0
890
891/** Whether a click on `tile` acts: in the dungeon a seen floor tile, in town open ground or a townsperson's spot. */
892const isClickable = (g: Game, tile: Pos) => {
893 if (g.where === 'town') return isTownWalkable(tile) || townSpotAt(tile) !== undefined
894 return tile.x >= 0 && tile.y >= 0 && tile.x < MAP_W && tile.y < MAP_H && g.seen[cellId(tile)] === '1' && isFloor(levelOf(g).tiles, tile)
895}
896
897/**
898 * What the hover shows on the map for the game as it is now, the pointer where it rests: the tile lit while a click
899 * there would act, and the name of an item seen lying there. Worked out on every draw, so a step, a pickup or a new
900 * depth never leaves a stale one.
901 */
902const mapHoverOf = (g: Game, pointer = mapPointer): { lit?: PixelRect; label: MapLabel | null } => {
903 if (pointer === null) return { label: null }
904 const size = { columns: pointer.columns, rows: pointer.rows, covered: mapCovered(pointer.columns, pointer.rows) }
905 const view = camera?.place === placeOf(g) ? camera.at : undefined
906 const litAt = (tile: Pos) => (isClickable(g, tile) ? tileRect(g, tile, size, view) : undefined)
907 const held = pinned === null ? null : labelFor(g, pinned, size, view)
908 const row = held === null ? undefined : labelRowAt({ ...held, lines: held.lines.map(l => l.mark + l.text) }, { x: Math.floor(pointer.x), y: Math.floor(pointer.y) })
909 if (held !== null && row !== undefined) return { lit: litAt(held.tile), label: { ...held, row } }
910 const tile = clickToTile(g, { ...pointer, covered: size.covered }, view)
911 return { lit: litAt(tile), label: labelFor(g, tile, size, view) }
912}
913
914/** The label of what lies on a seen tile, in the map's cells; null with nothing there. */
915const labelFor = (g: Game, tile: Pos, size: { columns: number; rows: number; covered?: number }, view: Pos | undefined): MapLabel | null => {
916 const lying = g.where === 'dungeon' && g.seen[cellId(tile)] === '1' ? (g.floor ?? []).filter(f => f.x === tile.x && f.y === tile.y).map(f => f.item) : []
917 if (lying.length === 0) return null
918 const { lines } = lyingLabel(lying, pinned !== null && same(tile, pinned) ? labelPage : 0, i => isUpgrade(g, i))
919 // An arrow before what beats what is worn; with one on the page, every item's row keeps room for it.
920 const anyUp = lines.some(l => l.isUp === true)
921 const mark = (l: (typeof lines)[number]) => (l.turnsPage === true || !anyUp ? '' : l.isUp === true ? '\u25b2 ' : ' ')
922 const at = mapLabelAt(tileToCell(g, tile, size, view), lines.map(l => mark(l) + l.text), size)
923 return { x: at.x, y: at.y, tile, lines: lines.map(l => ({ mark: mark(l), text: l.text, color: l.color ?? INK.dim, id: l.id, turnsPage: l.turnsPage })) }
924}
925
926/** A run of the map's click layer's text. */
927type MapRun = { text: string; color?: string; bold?: boolean; dim?: boolean }
928
929/** A value as plain data, keys holding undefined dropped: the pane refuses a Client prop holding undefined and draws
930 * nothing at all. */
931const plain = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
932
933/** What the map's click layer writes for the hover: the label, each cell on the map drawn under it (darkened), the row
934 * the pointer is on lit; and with that row an item, its card beside the label, as the bag's compares it. */
935const mapBlocks = (g: Game, label: MapLabel | null, frame: Uint32Array, size: { columns: number; rows: number }) => {
936 if (label === null) return []
937 const rows: MapRun[][] = label.lines.map(l => [...(l.mark === '' ? [] : [{ text: l.mark, color: INK.good }]), { text: l.text, color: l.color }])
938 const blocks: { x: number; y: number; rows: MapRun[][]; grounds: string[][] }[] = [{ x: label.x, y: label.y, rows, grounds: labelGrounds(frame, size.columns, { ...label, lines: label.lines.map(l => l.mark + l.text) }, label.row) }]
939 const id = label.row === undefined ? undefined : label.lines[label.row]?.id
940 const item = id === undefined ? undefined : (g.floor ?? []).find(f => f.item.id === id)?.item
941 if (item !== undefined) {
942 const lines = card(g, item, undefined, true).map(l => l.map(r => ({ text: r.text, color: r.color, bold: r.bold, dim: r.dim })))
943 const texts = lines.map(l => l.map(r => r.text).join(''))
944 const wide = labelWidth(texts)
945 const right = label.x + labelWidth(label.lines.map(l => l.mark + l.text)) + 1
946 const x = right + wide <= size.columns ? right : Math.max(0, label.x - wide - 1)
947 const y = Math.max(0, Math.min(label.y, size.rows - lines.length))
948 blocks.push({ x, y, rows: lines, grounds: labelGrounds(frame, size.columns, { x, y, lines: texts }) })
949 }
950 return plain(blocks)
951}
952
953/** The pointer over the map (or off it): where it rests kept, the map drawn again when what the hover shows changes. */
954const onMapHover = async ($: EngineInterface, data: { x?: number; y?: number; columns?: number; rows?: number }) => {
955 const g = await current($)
956 const before = JSON.stringify(mapHoverOf(g))
957 mapPointer = data.x !== undefined && data.y !== undefined && data.columns !== undefined && data.rows !== undefined ? { x: data.x, y: data.y, columns: data.columns, rows: data.rows } : null
958 const tile = mapHoverOf(g).label?.tile ?? null
959 if (tile === null || pinned === null || !same(tile, pinned)) labelPage = 0
960 pinned = tile
961 if (JSON.stringify(mapHoverOf(g)) !== before) $.ui.invalidate('ui.render')
962}
963
964/** A click on the map: on a label's row, that item picked from its tile, the row turning the page the next page; else
965 * the hero walks to the tile under it. */
966const onMapClick = async ($: EngineInterface, data: unknown) => {
967 await resume($)
968 const g = await current($)
969 const click = data as { x: number; y: number; columns: number; rows: number }
970 const { label } = mapHoverOf(g, click)
971 const line = label?.row === undefined ? undefined : label.lines[label.row]
972 if (line?.turnsPage === true) {
973 labelPage += 1
974 $.ui.invalidate('ui.render')
975 } else if (label?.row !== undefined) await act($, line?.id === undefined ? { kind: 'goto', ...label.tile } : { kind: 'pick', ...label.tile, id: line.id }, true)
976 else await act($, { kind: 'goto', ...clickToTile(g, { ...click, covered: mapCovered(click.columns, click.rows) }, camera?.place === placeOf(g) ? camera.at : undefined) }, true)
977}
978
979const onBagClick = async ($: EngineInterface, data: unknown) => {
980 const outcome = bagGesture(await current($), bagUi, data as Gesture)
981 bagUi = outcome.ui
982 await actAll($, outcome.actions)
983 $.ui.invalidate('ui.render')
984}
985
986const onShopClick = async ($: EngineInterface, data: unknown) => {
987 const outcome = shopGesture(await current($), shopUi, data as Gesture)
988 shopUi = outcome.ui
989 await actAll($, outcome.actions)
990 $.ui.invalidate('ui.render')
991}
992
993const onCbagClick = async ($: EngineInterface, data: unknown) => {
994 const outcome = compactGesture(await current($), bagUi, data as Gesture)
995 bagUi = outcome.ui
996 await actAll($, outcome.actions)
997 $.ui.invalidate('ui.render')
998}
999
1000const onSheetClick = async ($: EngineInterface, data: unknown) => actAll($, sheetGesture(await current($), data as Gesture))
1001
1002const onStartClick = async ($: EngineInterface, data: unknown) => {
1003 const g = await current($)
1004 const outcome = startGesture(g, startUi ?? { cls: g.cls }, data as Gesture)
1005 startUi = outcome.ui
1006 if (outcome.isHelp === true) isHelpOpen = true
1007 await actAll($, outcome.actions)
1008 $.ui.invalidate('ui.render')
1009}
1010
1011const onForgeClick = async ($: EngineInterface, data: unknown) => {
1012 const outcome = forgeGesture(await current($), forgeUi, data as Gesture)
1013 forgeUi = outcome.ui
1014 await actAll($, outcome.actions)
1015 $.ui.invalidate('ui.render')
1016}
1017
1018const onTalentClick = async ($: EngineInterface, data: unknown) => {
1019 const outcome = talentGesture(await current($), talentUi, data as Gesture)
1020 talentUi = outcome.ui
1021 await actAll($, outcome.actions)
1022 $.ui.invalidate('ui.render')
1023}
1024const onDeepClick = async ($: EngineInterface, data: unknown) => actAll($, deepGesture(await current($), data as Gesture))
1025
1026const onRetireClick = async ($: EngineInterface, data: unknown) => {
1027 const outcome = retireGesture(await current($), retireUi, data as Gesture)
1028 retireUi = outcome.ui
1029 await actAll($, outcome.actions)
1030 $.ui.invalidate('ui.render')
1031}
1032
1033const onChestClick = async ($: EngineInterface, data: unknown) => {
1034 const outcome = chestGesture(await current($), chestUi, data as Gesture)
1035 chestUi = outcome.ui
1036 await actAll($, outcome.actions)
1037 $.ui.invalidate('ui.render')
1038}
1039
1040const onTalkClick = async ($: EngineInterface, data: unknown) => {
1041 const g = await current($)
1042 if (isSpeaker(g.panel)) await actAll($, talkGesture(g, g.panel, data as Gesture))
1043}
1044
1045const onHudClick = async ($: EngineInterface, data: unknown) => {
1046 const outcome = stripGesture(await current($), beltMenu, data as Gesture, hudWidth)
1047 beltMenu = outcome.menu
1048 if (outcome.isLog === true) isLogOpen = true
1049 await actAll($, outcome.actions)
1050 $.ui.invalidate('ui.render')
1051}
1052
1053/** A point of the dash's layer over the map, on the map layer under it. */
1054const onMapUnder = (at: Pos, map: { columns: number; rows: number }) => ({ x: at.x, y: at.y + map.rows - OVER_ROWS, ...map })
1055
1056/** The pointer on the dash's layer over the map: where it shows the map, the map's hover there; on the dash, none. */
1057const onDashHover = ($: EngineInterface, at: Pos | null) =>
1058 onMapHover($, at !== null && mapSize !== null && isMapUnder(mapSize.columns, at) ? onMapUnder(at, mapSize) : {})
1059
1060/** A gesture on the dash's layer over the map: its orbs and crests, its plaque's buttons and words; between them, the map. */
1061const onDashClick = async ($: EngineInterface, data: unknown) => {
1062 const outcome = overMapGesture(data as Gesture, hudWidth)
1063 if (outcome.toMap !== undefined && mapSize !== null) return onMapClick($, onMapUnder(outcome.toMap, mapSize))
1064 if (outcome.isLog === true) isLogOpen = true
1065 await actAll($, outcome.actions)
1066 $.ui.invalidate('ui.render')
1067}
1068
1069const onDeathClick = async ($: EngineInterface, data: unknown) => actAll($, deathGesture(await current($), data as Gesture))
1070
1071const onRecapClick = async ($: EngineInterface, data: unknown) => actAll($, recapGesture(await current($), data as Gesture))
1072
1073const onCodexClick = async ($: EngineInterface, data: unknown) => {
1074 const g = await current($)
1075 const outcome = codexGesture(g, codexUi, data as Gesture)
1076 codexUi = outcome.ui
1077 if (outcome.isCard) await saveCard($, g)
1078 await actAll($, outcome.actions)
1079 $.ui.invalidate('ui.render')
1080}
1081
1082/** The card's file name, in the player's Downloads (their home folder without one). */
1083const CARD_FILE = 'ascii-dungeon-hero.svg'
1084
1085/** Where the hero card goes: Downloads under the home folder when there is one, else the home folder, else the working directory. */
1086const cardPath = async ($: EngineInterface) => {
1087 const home = await $.env.get('HOME')
1088 if (home === undefined) return { path: CARD_FILE, said: CARD_FILE }
1089 const isDownloads = await $.fs.exists(`${home}/Downloads`)
1090 return isDownloads ? { path: `${home}/Downloads/${CARD_FILE}`, said: `~/Downloads/${CARD_FILE}` } : { path: `${home}/${CARD_FILE}`, said: `~/${CARD_FILE}` }
1091}
1092
1093/** The hero card saved as an SVG and copied as text, and the toast saying what came of each. */
1094const saveCard = async ($: EngineInterface, g: Game) => {
1095 const { path, said } = await cardPath($)
1096 const saved = await $.fs.write(path, svgOf(cardPixels(g))).then(() => true, () => false)
1097 const copied = await $.ui.copy({ text: cardText(g) })
1098 const text = copied.isCopied ? 'Text copied.' : 'The text could not be copied.'
1099 $.ui.toast(saved ? `Hero card saved to ${said}. ${text}` : `The hero card could not be saved to ${said}. ${text}`)
1100}
1101
1102const onSettingsClick = async ($: EngineInterface, data: unknown) => {
1103 const g = await current($)
1104 const outcome = settingsGesture(g, soundSettings, settingsUi, data as Gesture)
1105 settingsUi = outcome.ui
1106 if (outcome.isHelp === true) isHelpOpen = true
1107 setSound($, g, outcome.sound)
1108 await actAll($, outcome.actions)
1109 $.ui.invalidate('ui.render')
1110}
1111
1112/** A gesture to the window whose click layer it came from; false when no layer of the pane's sent it. */
1113const onClick = async ($: EngineInterface, element: string, data: unknown) => {
1114 switch (element) {
1115 case 'map-clicks':
1116 await onMapClick($, data)
1117 return true
1118 case 'bag-clicks':
1119 await onBagClick($, data)
1120 return true
1121 case 'shop-clicks':
1122 await onShopClick($, data)
1123 return true
1124 case 'cbag-clicks':
1125 await onCbagClick($, data)
1126 return true
1127 case 'sheet-clicks':
1128 await onSheetClick($, data)
1129 return true
1130 case 'start-clicks':
1131 await onStartClick($, data)
1132 return true
1133 case 'forge-clicks':
1134 await onForgeClick($, data)
1135 return true
1136 case 'chest-clicks':
1137 await onChestClick($, data)
1138 return true
1139 case 'talk-clicks':
1140 await onTalkClick($, data)
1141 return true
1142 case 'hud-clicks':
1143 await onHudClick($, data)
1144 return true
1145 case 'dash-clicks':
1146 await onDashClick($, data)
1147 return true
1148 case 'death-clicks':
1149 await onDeathClick($, data)
1150 return true
1151 case 'recap-clicks':
1152 await onRecapClick($, data)
1153 return true
1154 case 'codex-clicks':
1155 await onCodexClick($, data)
1156 return true
1157 case 'retire-clicks':
1158 await onRetireClick($, data)
1159 return true
1160 case 'talent-clicks':
1161 await onTalentClick($, data)
1162 return true
1163 case 'deep-clicks':
1164 await onDeepClick($, data)
1165 return true
1166 case 'log-clicks':
1167 if (logGesture(data as Gesture)) isLogOpen = false
1168 $.ui.invalidate('ui.render')
1169 return true
1170 case 'help-clicks':
1171 if (helpGesture(data as Gesture)) isHelpOpen = false
1172 $.ui.invalidate('ui.render')
1173 return true
1174 case 'settings-clicks':
1175 await onSettingsClick($, data)
1176 return true
1177 default:
1178 return false
1179 }
1180}
1181
1182/** Plays the bench walk on screen only (the rules never move), after forgetting what was measured before. */
1183const startBench = async ($: EngineInterface) => {
1184 if (mapSize === null || !isPixelDash) return { text: 'Open the dungeon pane on the pixel map, then run /dungeon-bench again.' }
1185 await settle($)
1186 const bench = normalize({ ...newGame(BENCH_SEED), seen: '1'.repeat(MAP_W * MAP_H) })
1187 ticker.reset()
1188 repaints.reset()
1189 redraws.reset()
1190 keptMaps.reset()
1191 builtMaps.reset()
1192 isBenching = true
1193 startWalk($, benchRoute(bench, BENCH_TILES), bench, bench)
1194 return { text: `Bench walk started: ${BENCH_TILES} tiles, about ${Math.ceil((BENCH_TILES * FRAMES_PER_TILE * FRAME_MS) / 1000)} s. Run /dungeon-frames once the hero stops.` }
1195}
1196
1197/** The session begun: the commands, the idle motion, the saved game opened if the session has none yet, the pane. */
1198const beginSession = async ($: EngineInterface) => {
1199 // A load starts the sound from the options; what was set in settings since is kept, and wins.
1200 soundSettings = soundFrom(await $.store.get(SOUND).catch(() => undefined), soundSettings)hooks/town.ts 118 lines1// The town: a fixed square of shops reached by portal, lit by day, with no foes. Pure data and walking.
2import type { Pos } from './map'
3import { bfs, same } from './map'
4
5/**
6 * The ground, by tile. Buildings: R red roof, S slate roof, P teal roof, W timber wall, X stone wall, A awning,
7 * C counter; H the forest floor under the tree line. Open ground: f forge floor, = road, . grass, , flowers.
8 */
9export const TOWN_GROUND: readonly string[] = [
10 'HHHHHHHHHHHHHHHHHHHH',
11 'H.RRRR..SSSS..PPPP.H',
12 'H.RRRR..SSSS..PPPP.H',
13 'H.WWWW..XXXX..WWWW.H',
14 'H.AAAA..XXXX..AAAA.H',
15 'H.CCCC..ffff..CCCC.H',
16 'H.================.H',
17 'H.===..======..===.H',
18 'H.==.,,.====.,,.==.H',
19 'H.==.,,.====.,,.==.H',
20 'H.===..======..===.H',
21 'H.================.H',
22 'H........==........H',
23 'H........==........H',
24 'HHHHHHHHH==HHHHHHHHH',
25]
26export const TOWN_W = TOWN_GROUND[0]!.length
27export const TOWN_H = TOWN_GROUND.length
28
29export type TownSpot = 'merchant' | 'smith' | 'healer' | 'stash' | 'portal' | 'elder' | 'mystic' | TownPerson
30/** The townsfolk without work yet: visited, they have a word. */
31export type TownPerson = 'guard' | 'villager' | 'dog'
32
33/** Who and what the hero visits, each on its own tile: the shopkeepers at their counters, the smith at his anvil with the stash beside it on the forge floor, the rest about the square. */
34export const TOWN_SPOTS: Readonly<Record<TownSpot, Pos>> = {
35 merchant: { x: 3, y: 5 },
36 smith: { x: 8, y: 5 },
37 healer: { x: 15, y: 5 },
38 stash: { x: 11, y: 5 },
39 portal: { x: 9, y: 12 },
40 elder: { x: 8, y: 10 },
41 mystic: { x: 16, y: 9 },
42 guard: { x: 11, y: 12 },
43 villager: { x: 4, y: 7 },
44 dog: { x: 5, y: 7 },
45}
46
47/** What each townsperson without work says when visited. */
48export const TOWN_TALK: Readonly<Record<TownPerson, string>> = {
49 guard: 'The guard: "The dungeon is through the portal. Mind the stairs."',
50 villager: 'A villager waves. "Fine day for it."',
51 dog: 'The dog wags its tail.',
52}
53
54/** Where the portal sets the hero down: just north of it, on the road from the gate. */
55export const TOWN_ARRIVAL: Pos = { x: 9, y: 11 }
56
57export type TownDecor = { art: string; x: number; y: number; size: 1 | 2; blocks: boolean; alt?: boolean }
58
59/** The tree line: overlapping trees along every edge, a gap where the road leaves by the gate; every third in the second green. */
60const treeLine = (): TownDecor[] => {
61 const at: Pos[] = []
62 for (let x = -1; x <= TOWN_W - 1; x += 2) at.push({ x, y: -1 })
63 for (let x = -1; x <= TOWN_W - 1; x += 2) if (x < 8 || x > 10) at.push({ x, y: TOWN_H - 1 })
64 for (let y = 1; y <= TOWN_H - 2; y += 2) at.push({ x: -1, y }, { x: TOWN_W - 1, y })
65 return at.sort((a, b) => a.y - b.y).map((p, i) => ({ art: 'tree', ...p, size: 2, blocks: true, alt: i % 3 === 1 }))
66}
67
68/**
69 * What stands about the town, drawn in this order (later over earlier): the tree line, then the town's own trees and
70 * fountain, then the small things. Signs, the furnace and the chimney sit on walls and block nothing more.
71 */
72export const TOWN_DECOR: readonly TownDecor[] = [
73 ...treeLine(),
74 { art: 'tree', x: 6, y: 1, size: 2, blocks: true, alt: true },
75 { art: 'tree', x: 12, y: 1, size: 2, blocks: true },
76 { art: 'fountain', x: 9, y: 8, size: 2, blocks: true },
77 { art: 'tree', x: 1, y: 12, size: 2, blocks: true },
78 { art: 'tree', x: 5, y: 12, size: 2, blocks: true, alt: true },
79 { art: 'tree', x: 13, y: 12, size: 2, blocks: true },
80 { art: 'tree', x: 17, y: 12, size: 2, blocks: true, alt: true },
81 { art: 'signCoin', x: 4, y: 3, size: 1, blocks: false },
82 { art: 'signAnvil', x: 10, y: 3, size: 1, blocks: false },
83 { art: 'signCross', x: 16, y: 3, size: 1, blocks: false },
84 { art: 'furnace', x: 9, y: 4, size: 1, blocks: false },
85 { art: 'chimney', x: 10, y: 1, size: 1, blocks: false },
86 { art: 'anvil', x: 9, y: 5, size: 1, blocks: true },
87 // At the top of the lane beside the shop, its dead end: lower down they would wall in the ground above them.
88 { art: 'barrel', x: 1, y: 1, size: 1, blocks: true },
89 { art: 'crate', x: 1, y: 2, size: 1, blocks: true },
90 { art: 'lamp', x: 6, y: 7, size: 1, blocks: true },
91 { art: 'lamp', x: 13, y: 7, size: 1, blocks: true },
92]
93
94export const townSpotAt = (p: Pos) => (Object.keys(TOWN_SPOTS) as TownSpot[]).find(k => same(TOWN_SPOTS[k], p))
95
96const isCovered = (d: TownDecor, p: Pos) => p.x >= d.x && p.x < d.x + d.size && p.y >= d.y && p.y < d.y + d.size
97
98/** The thing standing on a tile that blocks the way, if any. */
99export const townDecorAt = (p: Pos) => TOWN_DECOR.find(d => d.blocks && isCovered(d, p))
100
101const isOpenGround = (p: Pos) => ['=', '.', ',', 'f'].includes(TOWN_GROUND[p.y]?.[p.x] ?? '')
102
103/** Whether the hero can stand on a tile: open ground with no one and nothing standing on it. */
104export const isTownWalkable = (p: Pos) => isOpenGround(p) && townSpotAt(p) === undefined && townDecorAt(p) === undefined
105
106/** The town as bfs reads it: '.' where the hero can stand, '#' elsewhere. */
107const WALKABLE: readonly string[] = TOWN_GROUND.map((row, y) => [...row].map((_, x) => (isTownWalkable({ x, y }) ? '.' : '#')).join(''))
108
109/** The shortest walk to a tile, both ends included; null when it cannot be stood on or reached. */
110export const townPath = (from: Pos, to: Pos) => (isTownWalkable(to) ? bfs(WALKABLE, from, to) : null)
111
112/** The shortest walk to stand next to a spot (diagonals count), for talking to whoever stands there. */
113export const pathBeside = (from: Pos, spot: Pos) => {
114 const around = [-1, 0, 1].flatMap(dy => [-1, 0, 1].map(dx => ({ x: spot.x + dx, y: spot.y + dy }))).filter(p => !same(p, spot))
115 if (around.some(p => same(p, from))) return [from]
116 return around.map(p => townPath(from, p)).filter(p => p !== null).sort((a, b) => a.length - b.length)[0] ?? null
117}
118hooks/game.ts 2055 lines1import type { Attribute, ChampionMod, Codex, Consumable, Foe, Mark, Talent, Fortune, Game, HeroClass, Item, Nemesis, Orders, Panel, Quest, Rarity, Slot, Work } from '../types'
2import { CALM_CALLS, CALM_HEAL, HP_PER_LEVEL, KILL_TENSION, PEAK_HP, TENSION_PEAK, foeAtk, foeHp, foeXp, heroDamage, xpToLevel } from './balance'
3import { foeTurn, heroBlowOn } from './patterns'
4import { addToBelt, chooseTier, countOf, healingSlot, killConsumable, nameOf, slotOf, tickEffects, tierAt, useBelt, wareOf } from './consumables'
5import type { Tier, Ware } from './consumables'
6import { CLASS_NAME, RARE_KILLS, RARITY_NAME, RARITY_RANK, SET_SHARE, STARTING_KIT, TEMPER_MAX, baseOf, buyBack, arrange, canWear, cycle, destroy, equip, fitsAt, heirloomOf, gearBonus, isSetWhole, makeItem, priceOf, setOf, setPiece, wornPower, wornTrigger, reforgeItem, selected, sell, sellJunk, stash, take, temperCost, temperItem, temperStat, toggleJunk, toggleLock, unequip, place } from './items'
7import { BIG_EVERY, THEME_DEPTHS, bfs, cellId, chestAt, corridorAhead, floorThing, isFloor, same, themeOf, visibleFrom } from './map'
8import type { ChestKind, Level, Pos, Theme } from './map'
9import { hash, roll } from './rng'
10import { look, noted } from './recap'
11import { EMPTY_CODEX, legendariesOwned, recorded } from './codex'
12import { TALENTS, TALENT_EVERY, earned, hasTalent, offer, owed } from './talents'
13import { MARKS, circleSaid, fateOf, forkTier, isForkOpen, markHere, markOf, pairOf, roman, tierStart } from './deep'
14import { normalize } from './save'
15import { putIn, stashedItems, tabName, tabPrice, takeOut, whereToStore, whyNotBuy } from './stash'
16import type { StashTab } from './stash'
17import { CLASS_LEAN, VIGOR_HP, arrive, levelOf, newGame, reveal } from './world'
18import { TOWN_ARRIVAL, TOWN_SPOTS, TOWN_TALK, isTownWalkable, pathBeside, townPath, townSpotAt } from './town'
19import type { TownPerson, TownSpot } from './town'
20import { TheFoe, capitalized, countText, theFoe } from './names'
21
22export { normalize } from './save'
23export { SAVE_VERSION, levelOf, newGame } from './world'
24
25export type Action =
26 | { kind: 'explore'; steps: number }
27 | { kind: 'summon'; name: string }
28 | { kind: 'ally' }
29 /** A tool call's end: whether it succeeded, and what work it came to when it did. */
30 | { kind: 'outcome'; ok: boolean; what: string; work?: Work }
31 | { kind: 'quest'; text: string }
32 | { kind: 'descend' }
33 | { kind: 'attack' }
34 | { kind: 'potion' }
35 | { kind: 'walk' }
36 | { kind: 'move'; dx: number; dy: number }
37 | { kind: 'goto'; x: number; y: number }
38 | { kind: 'pick'; x: number; y: number; id: number }
39 /** Exactly these tiles, in order: what a walk cut short has visibly walked. */
40 | { kind: 'trail'; path: readonly Pos[] }
41 | { kind: 'stairs' }
42 | { kind: 'restart'; cls?: HeroClass }
43 | { kind: 'use'; slot: number; tier?: Tier }
44 | { kind: 'choose'; slot: number; tier: Tier }
45 | { kind: 'junk' }
46 | { kind: 'lock' }
47 | { kind: 'sellJunk' }
48 | { kind: 'arrange' }
49 | { kind: 'panel'; panel: Panel }
50 | { kind: 'cycle' }
51 | { kind: 'buy'; ware: Ware }
52 | { kind: 'reforge'; slot: Slot }
53 | { kind: 'temper'; slot: Slot }
54 /** The selected bag item into the stash: its own special tab when it has one, else plain tab `tab` (the first unless said) or the first with room; dropped at cells `at`, onto plain tab `tab` there. */
55 | { kind: 'store'; tab?: number; at?: Pos }
56 | { kind: 'buyTab'; tab: StashTab }
57 | { kind: 'fortune' }
58 | { kind: 'gamble' }
59 | { kind: 'tradeIn' }
60 /** The elder's pay for a quest done, asked for again: after a visit found the bag too full for the item. */
61 | { kind: 'claim' }
62 | { kind: 'pet' }
63 /** A stashed item into the bag, at the first place it fits, or at cells `at` it is dropped on. */
64 | { kind: 'retrieve'; id: number; at?: Pos }
65 /** A stashed item moved on its plain tab to cells `x`, `y`. */
66 | { kind: 'moveStashed'; id: number; x: number; y: number }
67 | { kind: 'select'; id: number }
68 | { kind: 'deselect' }
69 | { kind: 'place'; id: number; x: number; y: number }
70 | { kind: 'destroy' }
71 | { kind: 'portal' }
72 | { kind: 'equip' }
73 | { kind: 'unequip'; slot: Slot }
74 | { kind: 'sell' }
75 /** The last sale bought back at what it fetched. */
76 | { kind: 'buyBack' }
77 /** One of the hero's standing orders stepped to its next choice. */
78 | { kind: 'order'; order: keyof Orders }
79 /** The player looks at the pane: a click or a key, before what it does. */
80 | { kind: 'look' }
81 /** The smith's last reforge taken back: the piece kept as it was. */
82 | { kind: 'keepOld' }
83 /** The hero retires, passing down the piece they wear in `slot`; the start screen opens for the next. */
84 | { kind: 'retire'; slot: Slot }
85 /** One of the talents offered, picked. */
86 | { kind: 'talent'; talent: Talent }
87 /** One of the two marks the next tier is chosen of, chosen. */
88 | { kind: 'mark'; mark: Mark }
89 /** A session begun: what waits for the player's choice, said. */
90 | { kind: 'greet' }
91 | { kind: 'train'; stat: Attribute }
92 | { kind: 'zoom' }
93
94export type Run = { text: string; color?: string; bg?: string; bold?: boolean; inverse?: boolean; dim?: boolean }
95
96/** The log lines a game keeps, what the log window shows; the dashboard shows the last few (`LOG_SHOWN`). */
97export const LOG_KEPT = 30
98/** Foes appear this many tiles ahead on the hero's route: inside a typical pane's view of about 3.4 tiles each way at 16 px. */
99const FOE_AHEAD = 3
100const POINTS_PER_LEVEL = 3
101
102export const BESTIARY = [
103 { glyph: 'r', name: 'rat' },
104 { glyph: 'g', name: 'goblin' },
105 { glyph: 'k', name: 'kobold' },
106 { glyph: 'o', name: 'orc' },
107 { glyph: 's', name: 'skeleton' },
108 { glyph: 'T', name: 'troll' },
109 { glyph: 'W', name: 'wraith' },
110 { glyph: 'D', name: 'dragon' },
111]
112
113/** Bonus melee damage: half of Might, plus every worn item's attack. */
114export const attackBonus = (g: Game) => Math.floor(g.stats.might / 2) + gearBonus(g, 'atk')
115export const defense = (g: Game) => gearBonus(g, 'def')
116export const critChance = (g: Game) => Math.min(60, g.stats.focus * 3 + gearBonus(g, 'crit') + (g.fortune?.kind === 'luck' ? LUCK_CRIT : 0))
117export const xpGain = (g: Game, xp: number) => {
118 const gained = Math.floor(xp * (1 + g.stats.insight / 10 + gearBonus(g, 'xp') / 100))
119 return g.fortune?.kind === 'insight' ? Math.floor(gained * FORTUNE_MORE) : gained
120}
121
122/** Points of crit chance a fortune of luck adds. */
123export const LUCK_CRIT = 15
124/** The Band of the Endless Loop heals LOOP_HEAL HP on every LOOP_KILLS-th kill. */
125export const LOOP_KILLS = 5
126export const LOOP_HEAL = 3
127/** How much more gold (riches) or XP (insight) a fortune brings. */
128export const FORTUNE_MORE = 1.5
129
130export { xpToLevel } from './balance'
131
132export type Threat = 'easy' | 'tough' | 'deadly'
133
134/** How dangerous the foe is: by the hits it needs to drop the hero from full health. */
135export const threat = (g: Game): Threat => {
136 const hits = Math.ceil(g.maxHp / Math.max(1, (g.foe?.atk ?? 0) - defense(g)))
137 if (hits <= 3) return 'deadly'
138 return hits <= 6 ? 'tough' : 'easy'
139}
140
141const REPEAT = /^(.*) \(x(\d+)\)$/
142
143const say = (g: Game, line: string) => {
144 const last = g.log[g.log.length - 1] ?? ''
145 const counted = REPEAT.exec(last)
146 if ((counted?.[1] ?? last) === line) {
147 g.log = [...g.log.slice(0, -1), `${line} (x${Number(counted?.[2] ?? 1) + 1})`]
148 g.fresh = Math.max(g.fresh, 1)
149 return
150 }
151 g.log = [...g.log, line].slice(-LOG_KEPT)
152 g.fresh = Math.min(LOG_KEPT, g.fresh + 1)
153}
154
155const levelUp = (g: Game) => {
156 while (g.xp >= xpToLevel(g.level)) {
157 g.xp -= xpToLevel(g.level)
158 g.level += 1
159 g.maxHp += HP_PER_LEVEL
160 g.hp = g.maxHp
161 g.points += POINTS_PER_LEVEL
162 g.toast = `Level up! You are now level ${g.level}.`
163 say(g, `*** Level ${g.level}! HP restored, +${POINTS_PER_LEVEL} stat points (open the character sheet). ***`)
164 if (g.level % TALENT_EVERY === 0 && owed(g) > 0) say(g, 'A talent to choose: open the character sheet.')
165 }
166}
167
168const die = (g: Game, killer: string) => {
169 const foe = g.foe !== null && killer === theFoe(g.foe) ? g.foe : undefined
170 const depth = g.depth
171 const at = { x: g.x, y: g.y }
172 g.deaths += 1
173 g.toast = `Slain by ${killer} on depth ${g.depth}.`
174 // Back to the first depth of the theme, or the depth a commit marked in it: what was won below it stays won, and the depths above are not walked again.
175 const first = Math.floor((g.depth - 1) / THEME_DEPTHS) * THEME_DEPTHS + 1
176 const wake = g.waypoint !== undefined && g.waypoint >= first && g.waypoint <= g.depth ? g.waypoint : first
177 say(g, `You were slain by ${killer} on depth ${g.depth}. You wake on depth ${wake}.`)
178 if (foe !== undefined) remember(g, foe)
179 // Waking on the same depth, what lies on its floor still lies there; on another, it is lost, said.
180 const lying = g.floor ?? []
181 if (wake !== depth && lying.length > 0) say(g, `${lying.length === 1 ? 'The item' : `The ${lying.length} items`} left on the floor of depth ${depth} ${lying.length === 1 ? 'is' : 'are'} lost.`)
182 Object.assign(g, arrive(g.world, wake), { walked: [], fortune: null, tension: 0, calm: 0, lastSold: null }, wake === depth ? { floor: lying } : {})
183 // The waking depth's twist is said; the toast stays the death's.
184 const mod = depthMod(g.world, wake)
185 if (mod !== undefined) say(g, `${MOD_SAID[mod]}.`)
186 g.hp = g.maxHp
187 const lost = g.gold - Math.floor(g.gold / 2)
188 g.gold -= lost
189 g.foe = null
190 g.fallen = { by: killer, ...(foe === undefined ? {} : { foe }), at, depth, wake, lost }
191 g.panel = 'death'
192}
193
194const hurt = (g: Game, amount: number, by: string) => {
195 g.hp -= amount
196 if (g.hp <= 0 && hasTalent(g, 'secondWind') && g.wind !== g.depth) {
197 g.hp = 1
198 g.wind = g.depth
199 say(g, 'Second wind: you stand, at 1 HP.')
200 }
201 if (g.hp <= 0) die(g, by)
202 else obey(g)
203}
204
205/** The orders a game keeps until the player changes them: drink under a quarter of max HP, no smoke, take every item. */
206export const DEFAULT_ORDERS: Orders = { drink: 25, smoke: false, leave: 'common' }
207export const ordersOf = (g: Pick<Game, 'orders'>) => g.orders ?? DEFAULT_ORDERS
208
209/** The hero's orders after a blow: a healing potion under the drink line; with none, smoke when under a quarter and a foe is near. */
210const obey = (g: Game) => {
211 const orders = ordersOf(g)
212 const healing = healingSlot(g)
213 if (healing >= 0 && g.hp * 100 < orders.drink * g.maxHp) say(g, useBelt(g, healing))
214 else if (orders.smoke && healing < 0 && g.foe !== null && g.hp * 4 < g.maxHp && countOf(g.belt[slotOf('smoke')]) > 0) say(g, useBelt(g, slotOf('smoke')))
215}
216
217/** Each order's choices in the order a click steps through them. */
218const ORDER_STEPS: { [K in keyof Orders]: readonly Orders[K][] } = { drink: [25, 50, 0], smoke: [false, true], leave: ['common', 'uncommon', 'rare', 'all'] }
219
220const order = (g: Game, which: keyof Orders) => {
221 const orders = ordersOf(g)
222 const steps = ORDER_STEPS[which] as readonly unknown[]
223 g.orders = { ...orders, [which]: steps[(steps.indexOf(orders[which]) + 1) % steps.length] }
224}
225
226/** Furnishings: braziers, shelves, reeds and embers, nothing to take. */
227const FURNISHINGS = new Set(['B', 'L', 'Q', 'F'])
228
229/**
230 * Whether the thing on the hero's tile is there to take now: not a furnishing; the altar and the spring passed by
231 * while unhurt, and the old anvil while the weapon cannot take a temper, each kept for later.
232 */
233const isTakenNow = (g: Game, found: string) => {
234 const weapon = g.gear.weapon
235 const isAnvilIdle = found === 'N' && (weapon === undefined || (weapon.temper ?? 0) >= TEMPER_MAX)
236 const isUnhurt = (found === 'A' || found === 'S') && g.hp >= g.maxHp
237 return !FURNISHINGS.has(found) && !isUnhurt && !isAnvilIdle
238}
239
240const openChestHere = (g: Game) => openChest(g, chestAt(g.world, g.depth, levelOf(g), g) ?? 'wooden')
241
242/** What each thing on the floor does as it is taken. */
243const ON_FLOOR: Readonly<Record<string, (g: Game) => void>> = {
244 $: g => {
245 const coins = (roll(g, 5) + g.depth) * (modHere(g) === 'hoard' ? HOARD_GOLD : 1)
246 g.gold += coins
247 say(g, `You pick up ${countText(coins)} gold.`)
248 },
249 '!': g => say(g, addToBelt(g, 'health') ? 'You find a Health Potion.' : 'Your belt is full; the potion stays behind.'),
250 '^': g => {
251 say(g, 'A trap snaps at your ankle! -2 HP')
252 hurt(g, 2, 'a trap')
253 },
254 A: g => {
255 const healed = Math.min(g.maxHp - g.hp, Math.ceil(g.maxHp / 4))
256 g.hp += healed
257 say(g, `You pray at the altar. +${healed} HP`)
258 },
259 S: g => {
260 g.hp = g.maxHp
261 say(g, 'You drink from the spring. HP full.')
262 },
263 N: g => {
264 const weapon = g.gear.weapon!
265 g.gear = { ...g.gear, weapon: temperItem(weapon) }
266 say(g, `The old anvil still rings: your ${weapon.name} is tempered, +1 ${temperStat(weapon) === 'atk' ? 'attack' : 'defense'}.`)
267 },
268 R: g => say(g, 'You read at the lectern and copy out a Scroll of the Berserker.'),
269 C: openChestHere,
270 H: openChestHere,
271 G: openChestHere,
272}
273
274/** Each theme's thing to gather (`GATHERABLE`), by glyph: the consumable it gives and where it comes from. */
275const GATHER: Readonly<Record<string, { kind: Consumable; from: string }>> = {
276 U: { kind: 'ward', from: 'the warding stone' },
277 V: { kind: 'regen', from: 'the reeds' },
278 P: { kind: 'regen', from: 'the glowing caps' },
279 X: { kind: 'ward', from: 'the rime crystal' },
280 K: { kind: 'smoke', from: 'the powder keg' },
281}
282
283/** What gathering here gives: its kind, and a tier by depth drawn from the world and the cell, so the dice are left alone. */
284const gatheredHere = (g: Game) => {
285 const gather = GATHER[floorThing(g.world, g.depth, levelOf(g), g)]
286 return gather === undefined ? undefined : { ...gather, tier: tierAt(g.depth, hash(g.world * 3 + g.depth, g.x, g.y) + 1) }
287}
288
289/** The theme's thing gathered onto the belt and said; false, and said, when its tier has no room there. */
290const gather = (g: Game) => {
291 const found = gatheredHere(g)!
292 const name = nameOf(found.kind, found.tier)
293 if (!addToBelt(g, found.kind, found.tier)) {
294 say(g, `Your belt has no room for the ${name} ${found.from} offers.`)
295 return false
296 }
297 say(g, `You gather ${/^[AEIOU]/.test(name) ? 'an' : 'a'} ${name} from ${found.from}.`)
298 return true
299}
300
301const pickUp = (g: Game) => {
302 const at = cellId(g)
303 const found = g.taken.includes(at) ? ' ' : floorThing(g.world, g.depth, levelOf(g), g)
304 const tile = isTakenNow(g, found) ? found : ' '
305 // The lectern's scroll needs room on the belt before the lectern counts as read; so does what is gathered.
306 if (tile === 'R' && !addToBelt(g, 'fury', 3)) {
307 say(g, 'Your belt has no room for the scroll the lectern offers.')
308 return
309 }
310 if (GATHER[tile] !== undefined && !gather(g)) return
311 if (tile !== ' ') g.taken = [...g.taken, at]
312 ON_FLOOR[tile]?.(g)
313}
314
315/** What a chest holds, by kind: gold rolled up from a base per depth, and an item no worse than its promise. */
316const CHEST_LOOT: Readonly<Record<ChestKind, { gold: number; perDepth: number; itemChance: number; atLeast: Rarity }>> = {
317 wooden: { gold: 10, perDepth: 3, itemChance: 60, atLeast: 'common' },
318 iron: { gold: 15, perDepth: 5, itemChance: 100, atLeast: 'uncommon' },
319 gilded: { gold: 25, perDepth: 8, itemChance: 100, atLeast: 'rare' },
320}
321
322/** Opens a chest: its gold, and its item into the bag if it fits, said in one line. */
323const openChest = (g: Game, kind: ChestKind) => {
324 const loot = CHEST_LOOT[kind]
325 const coins = (roll(g, loot.gold) + loot.perDepth * g.depth) * (modHere(g) === 'hoard' ? HOARD_GOLD : 1)
326 g.gold += coins
327 const item = roll(g, 100) <= loot.itemChance ? makeItem(g, loot.atLeast) : undefined
328 if (item !== undefined) announce(g, item)
329 const isKept = item === undefined || stash(g, item)
330 if (!isKept) leaveOnFloor(g, item, g)
331 const found = item === undefined ? '' : isKept ? ` and ${item.name} (${RARITY_NAME[item.rarity]})` : `; your bag is full, so the ${item.name} lies here`
332 say(g, `You open the ${kind} chest: ${countText(coins)} gold${found}.`)
333}
334
335/** How far the hero sees in a corridor: a tile around, two with Pathfinder. */
336export const sightOf = (g: Pick<Game, 'talents'>) => (hasTalent(g, 'pathfinder') ? 2 : 1)
337
338const moveTo = (g: Game, p: Pos) => {
339 g.x = p.x
340 g.y = p.y
341 g.walked = [...g.walked, { x: p.x, y: p.y }]
342 const level = levelOf(g)
343 g.seen = reveal(g.seen, [...visibleFrom(level, p, sightOf(g)), ...corridorAhead(level, p)])
344 // What lies here first, so a chest opened with the bag full says once where its item lies.
345 pickUpFloor(g)
346 pickUp(g)
347}
348
349/** The way from the hero to the stairs, around the foe; null when the foe cuts it. */
350export const routeToStairs = (g: Game) => bfs(levelOf(g).tiles, g, levelOf(g).stairs, g.foe ?? undefined)
351
352const isNextTo = (a: Pos, b: Pos) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)) <= 1
353
354/**
355 * Exploring walks the plain route to the stairs and stops at a living foe: beside it, never on it.
356 * A foe in the way is walked up to and has to be dealt with before the hero goes on.
357 */
358const step = (g: Game, steps: number) => {
359 if (g.foe !== null && isNextTo(g, g.foe)) {
360 say(g, `${TheFoe(g.foe)} blocks your way.`)
361 return
362 }
363 const level = levelOf(g)
364 const path = bfs(level.tiles, g, level.stairs)
365 if (path === null) {
366 say(g, 'No way on from here.')
367 return
368 }
369 if (path.length === 1) {
370 say(g, 'You stand on the stairs. Finish the task to descend.')
371 return
372 }
373 const deaths = g.deaths
374 startWalk(g)
375 for (const p of path.slice(1, steps + 1)) {
376 if (g.deaths !== deaths) return
377 moveTo(g, p)
378 if (g.foe !== null && isNextTo(g, g.foe)) return
379 }
380}
381
382/** Where a foe appears: up to FOE_AHEAD cells along the hero's route but always short of the stairs; else the nearest open cell in sight. */
383const foeSpot = (g: Game): Pos => {
384 const level = levelOf(g)
385 const path = bfs(level.tiles, g, level.stairs) ?? [g]
386 const ahead = path[Math.min(FOE_AHEAD, path.length - 2)]
387 if (ahead !== undefined && !same(ahead, g)) return ahead
388 const open = visibleFrom(level, g).filter(c => isFloor(level.tiles, c) && !same(c, g) && !same(c, level.stairs))
389 // The place alone: the game itself spread into a foe would hand it the hero's HP.
390 return open.sort((a, b) => Math.abs(a.x - g.x) + Math.abs(a.y - g.y) - (Math.abs(b.x - g.x) + Math.abs(b.y - g.y)))[0] ?? { x: g.x, y: g.y }
391}
392
393/** What each theme calls the monsters it has its own take on (their art in MONSTER_VARIANT); the crypt keeps the names. */
394export const VARIANT_NAME: Readonly<Partial<Record<Theme, Readonly<Record<string, string>>>>> = {
395 flooded: { r: 'bog rat', g: 'bog goblin', k: 'mire kobold', o: 'mire orc', s: 'drowned skeleton', T: 'moss troll', W: 'sunken wraith', D: 'sea drake' },
396 fungal: { r: 'spore rat', g: 'toadstool goblin', k: 'mold kobold', o: 'fungal orc', s: 'mossbone skeleton', T: 'cap troll', W: 'spore wraith', D: 'blight drake' },
397 frozen: { r: 'frost rat', g: 'ice goblin', k: 'rime kobold', o: 'frost orc', s: 'frozen skeleton', T: 'ice troll', W: 'frost wraith', D: 'frost drake' },
398 forge: { r: 'ember rat', g: 'cinder goblin', k: 'slag kobold', o: 'ash orc', s: 'charred skeleton', T: 'magma troll', W: 'flame wraith', D: 'forge drake' },
399}
400/** A monster's name on a depth: the theme's own where it has one. */
401export const kindName = (glyph: string, depth: number) => VARIANT_NAME[themeOf(depth)]?.[glyph] ?? BESTIARY.find(k => k.glyph === glyph)?.name ?? 'horror'
402
403/** A depth's twist, a risk with its reward: foes harder in one way, the depth paying better in another. */
404export type DepthMod = 'gloom' | 'horde' | 'den' | 'barren' | 'bloodlust' | 'hoard'
405export const DEPTH_MODS: readonly DepthMod[] = ['gloom', 'horde', 'den', 'barren', 'bloodlust', 'hoard']
406/** Of the depths past the first, boss depths aside, this many in a hundred have a twist. */
407export const MOD_SHARE = 40
408/** What each twist does, as the log says it on arrival. */
409export const MOD_SAID: Readonly<Record<DepthMod, string>> = {
410 gloom: 'Gloom: foes hit a fifth harder, and kills pay half again the gold',
411 horde: 'Horde: foes have a quarter more HP, and kills give half again the XP',
412 den: "Champions' den: one foe in four is a champion",
413 barren: 'Barren: no potions drop here, but gear drops more often',
414 bloodlust: 'Bloodlust: you and the foes both hit a quarter harder',
415 hoard: 'Hoard: gold on the floor and in chests is doubled, and foes hit a sixth harder',
416}
417export const MOD_NAME: Readonly<Record<DepthMod, string>> = { gloom: 'Gloom', horde: 'Horde', den: "Champions' den", barren: 'Barren', bloodlust: 'Bloodlust', hoard: 'Hoard' }
418const GLOOM_ATK = 1.2
419const HORDE_HP = 1.25
420const MOD_MORE = 1.5
421const DEN_ODDS = 4
422const BARREN_ITEMS = 15
423const BLOODLUST = 1.25
424const HOARD_ATK = 1.15
425const HOARD_GOLD = 2
426/** What a tier's mark makes of a foe's HP and attack (`MARKS` in hooks/deep.ts): Ironclad's and Frenzied's hit a sixth harder. */
427const MARK_TIMES: Readonly<Record<Mark, { hp: number; atk: number }>> = {
428 steady: { hp: 1, atk: 1 },
429 mild: { hp: 1, atk: 0.8 },
430 brittle: { hp: 0.8, atk: 1.2 },
431 ironclad: { hp: 1.5, atk: 7 / 6 },
432 frenzied: { hp: 1, atk: 7 / 6 },
433 lean: { hp: 1, atk: 1 },
434}
435const MILD_GOLD = 2 / 3
436const LEAN_GOLD = 2
437
438/** A depth's twist, the same every time it is drawn: picked from the world and the depth, none on depth 1 or a boss depth. */
439export const depthMod = (world: number, depth: number): DepthMod | undefined => {
440 if (depth < 2 || isBossDepth(depth) || hash(depth, world, 7) >= MOD_SHARE) return undefined
441 return DEPTH_MODS[hash(depth, world, 11) % DEPTH_MODS.length]
442}
443const modHere = (g: Game) => (g.where === 'dungeon' ? depthMod(g.world, g.depth) : undefined)
444
445/** On arriving at a depth: its twist said in full, and toasted by name. */
446const announceTwist = (g: Game) => {
447 const mod = depthMod(g.world, g.depth)
448 if (mod === undefined) return
449 say(g, `${MOD_SAID[mod]}.`)
450 g.toast = `Depth ${g.depth}: ${MOD_NAME[mod]}.`
451}
452
453/** The monsters each theme favors: there they come FAVORED_WEIGHT times as often as the rest. */
454const FAVORED: Readonly<Record<Theme, string>> = { crypt: 'rsW', flooded: 'kTr', fungal: 'rgk', frozen: 'sWo', forge: 'ogD' }
455const FAVORED_WEIGHT = 3
456/** The newest kinds a depth has unlocked come this many times as often as the older ones. */
457const NEW_WEIGHT = 2
458
459/**
460 * What kind a foe is, as its tier in the bestiary: any kind the depth has unlocked (up to two tiers past it), the
461 * theme's favorites and the newest three more often. Stats grow with depth whatever the kind, so an old kind is
462 * fodder beside a new one rather than harmless.
463 */
464export const foeTierWeights = (depth: number): number[] => {
465 const top = Math.min(BESTIARY.length - 1, depth + 1)
466 const favored = FAVORED[themeOf(depth)]
467 return BESTIARY.slice(0, top + 1).map((kind, tier) => (favored.includes(kind.glyph) ? FAVORED_WEIGHT : 1) * (tier >= top - 2 ? NEW_WEIGHT : 1))
468}
469
470const pickTier = (g: Game) => {
471 const weights = foeTierWeights(g.depth)
472 let n = roll(g, weights.reduce((a, b) => a + b, 0))
473 for (const [tier, w] of weights.entries()) {
474 n -= w
475 if (n <= 0) return tier
476 }
477 return weights.length - 1
478}
479
480const spawn = (g: Game, origin: string): Foe => {
481 const tier = pickTier(g)
482 const kind = BESTIARY[tier] ?? { glyph: '?', name: 'horror' }
483 const mod = modHere(g)
484 const mark = markHere(g)
485 const maxHp = Math.round(foeHp(g.depth, tier, roll(g, 4)) * (mod === 'horde' ? HORDE_HP : 1) * MARK_TIMES[mark].hp)
486 const foe: Foe = {
487 glyph: kind.glyph,
488 name: `${kindName(kind.glyph, g.depth)} of ${origin}`,
489 hp: maxHp,
490 maxHp,
491 atk: Math.round(foeAtk(g.depth, tier, roll(g, 2)) * (mod === 'gloom' ? GLOOM_ATK : mod === 'hoard' ? HOARD_ATK : 1) * MARK_TIMES[mark].atk),
492 xp: foeXp(g.depth, tier),
493 ...foeSpot(g),
494 }
495 if (g.depth >= 2 && roll(g, mod === 'den' || mark === 'frenzied' ? DEN_ODDS : CHAMPION_ODDS) === 1) return championOf(foe, CHAMPION_MODS[roll(g, CHAMPION_MODS.length) - 1]!)
496 return foe
497}
498
499/** Every BOSS_EVERY depths the stairs have a guardian, met on the first try to descend. */
500export const BOSS_EVERY = 5
501export const isBossDepth = (depth: number) => depth % BOSS_EVERY === 0
502
503/**
504 * A boss depth's guardian: the kind two tiers past the depth, at its strongest roll, with BOSS_HP times the HP and
505 * BOSS_ATK times the attack, four times the XP.
506 */
507export const BOSS_HP = 2.5
508export const BOSS_ATK = 1.45
509
510/** Every BIG_EVERY depths, in the guardian's place, a big boss: the theme's own (`BIG_BOSSES`). */
511export const isBigBossDepth = (depth: number) => depth % BIG_EVERY === 0
512export const BIG_BOSSES: Readonly<Record<Theme, { key: NonNullable<Foe['big']>; name: string }>> = {
513 crypt: { key: 'king', name: 'Bone King' },
514 flooded: { key: 'queen', name: 'Drowned Queen' },
515 fungal: { key: 'hive', name: 'Hive Mother' },
516 frozen: { key: 'lich', name: 'Frost Lich' },
517 forge: { key: 'wyrm', name: 'Furnace Wyrm' },
518}
519/** A big boss against the strongest kind at its depth's strongest roll: its HP and attack times these, eight times its XP. */
520export const BIG_HP = 4.5
521export const BIG_ATK = 1.5
522const bigBossOf = (g: Game): Foe => {
523 const tier = BESTIARY.length - 1
524 const b = BIG_BOSSES[themeOf(g.depth)]
525 const maxHp = Math.round(foeHp(g.depth, tier, 4) * BIG_HP)
526 return { glyph: '&', name: b.name, hp: maxHp, maxHp, atk: Math.ceil(foeAtk(g.depth, tier, 2) * BIG_ATK), xp: foeXp(g.depth, tier) * 8, ...foeSpot(g), boss: true, big: b.key }
527}
528
529const bossOf = (g: Game): Foe => {
530 const tier = Math.min(BESTIARY.length - 1, g.depth + 1)
531 const kind = BESTIARY[tier] ?? { glyph: '?', name: 'horror' }
532 const maxHp = foeHp(g.depth, tier, 4) * BOSS_HP
533 return { glyph: kind.glyph, name: `${kindName(kind.glyph, g.depth)} overlord`, hp: maxHp, maxHp, atk: Math.ceil(foeAtk(g.depth, tier, 2) * BOSS_ATK), xp: foeXp(g.depth, tier) * 4, ...foeSpot(g), boss: true }
534}
535
536export const CHAMPION_MODS: readonly ChampionMod[] = ['tough', 'savage', 'shielded', 'vampiric']
537/** The names a nemesis takes, and the title its modifier gives it. */
538const NEMESIS_NAMES: readonly string[] = ['Grak', 'Vex', 'Murk', 'Skarn', 'Thessa', 'Olg', 'Brakka', 'Zul', 'Mirra', 'Dask', 'Korv', 'Ysolde']
539const NEMESIS_TITLE: Readonly<Record<ChampionMod, string>> = { tough: 'Unbroken', savage: 'Butcher', shielded: 'Warden', vampiric: 'Bloodthirsty' }
540/** A nemesis waits this many depths below where it won, and a d4 more. */
541const NEMESIS_AHEAD = 3
542/** Each win makes a nemesis this much stronger again, in HP and attack, up to NEMESIS_GROWN_WINS of them: with no
543 * ceiling a nemesis ahead of the hero grows with every win and can never be caught. */
544export const NEMESIS_GROWTH = 0.2
545export const NEMESIS_GROWN_WINS = 3
546
547/** A nemesis by its title: "Grak the Butcher". */
548export const nemesisTitle = (n: Pick<Nemesis, 'given' | 'mod'>) => `${n.given} the ${NEMESIS_TITLE[n.mod]}`
549
550const kindOf = (glyph: string) => BESTIARY.find(k => k.glyph === glyph)?.name ?? 'horror'
551
552/** The nemesis that comes for the hero here: its kind at this depth at its strongest, a champion, stronger for each of
553 * its first wins. */
554const nemesisFoe = (g: Game, n: Nemesis): Foe => {
555 const tier = Math.max(0, BESTIARY.findIndex(k => k.glyph === n.glyph))
556 const hp = foeHp(g.depth, tier, 4)
557 const base: Foe = { glyph: n.glyph, name: `${n.kind} ${n.given}`, hp, maxHp: hp, atk: foeAtk(g.depth, tier, 2), xp: foeXp(g.depth, tier) * 2, ...foeSpot(g) }
558 const champion = championOf(base, n.mod)
559 const grow = 1 + NEMESIS_GROWTH * Math.min(n.wins, NEMESIS_GROWN_WINS)
560 return { ...champion, name: base.name, hp: Math.round(champion.hp * grow), maxHp: Math.round(champion.maxHp * grow), atk: Math.round(champion.atk * grow), nemesis: true }
561}
562
563/**
564 * A death by a champion makes it the hero's nemesis, or makes the nemesis that won again stronger; it goes deeper to
565 * wait. A boss is never a champion, so a boss's win makes none.
566 */
567const remember = (g: Game, slayer: Foe) => {
568 const waits = g.depth + NEMESIS_AHEAD + roll(g, 4)
569 if (slayer.nemesis === true && g.nemesis !== null) {
570 g.nemesis = { ...g.nemesis, wins: g.nemesis.wins + 1, depth: waits }
571 say(g, `${nemesisTitle(g.nemesis)} laughs, and goes deeper still. It waits on depth ${waits}.`)
572 } else if (slayer.champion !== undefined && g.nemesis === null) {
573 g.nemesis = { given: NEMESIS_NAMES[roll(g, NEMESIS_NAMES.length) - 1]!, kind: kindOf(slayer.glyph), glyph: slayer.glyph, mod: slayer.champion, depth: waits, wins: 1 }
574 say(g, `${TheFoe(slayer)} will remember you. Now it is ${nemesisTitle(g.nemesis)}, and it waits on depth ${waits}.`)
575 }
576}
577
578/** A nemesis slain at last: a trophy of Epic or better, half again a champion's gold, and it is gone. */
579const trophy = (g: Game, n: Nemesis, at: Pos) => {
580 const item = makeItem(g, 'epic', 2)
581 const title = nemesisTitle(n)
582 g.nemesis = null
583 keepPrize(g, item, at, named => `${title} falls at last. Trophy: ${named}.`)
584 g.toast = `${title} is slain!`
585}
586
587/** Each theme's named unique: who it is, what kind, and its trick (in `patterns.ts` and `strike`). */
588export const UNIQUES: Readonly<Record<Theme, { key: NonNullable<Foe['unique']>; name: string; glyph: string }>> = {
589 crypt: { key: 'bones', name: 'Old Bones', glyph: 's' },
590 flooded: { key: 'mire', name: 'Mother Mire', glyph: 'T' },
591 fungal: { key: 'rot', name: 'the Rotking', glyph: 'k' },
592 frozen: { key: 'rime', name: 'Rimehide', glyph: 'o' },
593 forge: { key: 'ember', name: 'Emberjaw', glyph: 'D' },
594}
595/** A unique against its kind at the depth: half again the HP, a tenth more attack, four times the XP. At twice the HP
596 * and a third more attack deaths rose by half: one that wins is still there when the hero comes back. */
597const UNIQUE_HP = 1.5
598const UNIQUE_ATK = 1.1
599const UNIQUE_XP = 4
600
601/** The band of five depths a depth lies in: 0 for depths 1 to 5. */
602export const bandOf = (depth: number) => Math.floor((depth - 1) / THEME_DEPTHS)
603/** Where a band's unique waits: one of its middle depths (never its first, never its boss depth), the same each time. */
604export const uniqueDepth = (world: number, band: number) => band * THEME_DEPTHS + 2 + (hash(band, world, 13) % 3)
605
606const uniqueFoe = (g: Game): Foe => {
607 const u = UNIQUES[themeOf(g.depth)]
608 const tier = Math.max(0, BESTIARY.findIndex(k => k.glyph === u.glyph))
609 const hp = foeHp(g.depth, tier, 4) * UNIQUE_HP
610 return { glyph: u.glyph, name: u.name, hp, maxHp: hp, atk: Math.round(foeAtk(g.depth, tier, 2) * UNIQUE_ATK), xp: foeXp(g.depth, tier) * UNIQUE_XP, ...foeSpot(g), unique: u.key }
611}
612
613/** One foe in this many, from depth 2, is a champion. */
614const CHAMPION_ODDS = 10
615
616/**
617 * A foe made a champion: its modifier first in its name, double XP, half again its HP and a quarter more attack, and
618 * one strength on top: tough has 60% more HP again, savage hits half again as hard, shielded turns the first blow
619 * aside, vampiric heals by what it deals, up to half of what it took since its last blow.
620 */
621export const championOf = (foe: Foe, mod: ChampionMod): Foe => {
622 const hp = Math.round(foe.hp * 1.5)
623 const maxHp = Math.round(foe.maxHp * 1.5)
624 const atk = Math.ceil(foe.atk * 1.25)
625 const named = { ...foe, name: `${mod.charAt(0).toUpperCase()}${mod.slice(1)} ${foe.name}`, xp: foe.xp * 2, champion: mod, hp, maxHp, atk }
626 if (mod === 'tough') return { ...named, hp: Math.round(hp * 1.6), maxHp: Math.round(maxHp * 1.6) }
627 if (mod === 'savage') return { ...named, atk: Math.ceil(atk * 1.5) }
628 if (mod === 'shielded') return { ...named, shield: 1 }
629 return named
630}
631
632/**
633 * Toasts a drop worth hearing about: a Legendary or an Epic by name, else a champion's prize; lesser drops are
634 * left to the bag, so the toasts stay rare enough to matter.
635 */
636const announce = (g: Game, item: Item, champion?: string) => {
637 if (item.rarity === 'legendary') g.toast = `Legendary! ${item.name}`
638 else if (item.rarity === 'epic') g.toast = `Epic! ${item.name}`
639 else if (champion !== undefined) g.toast = `Champion slain: ${champion} left ${item.name}.`
640}
641
642/** The percent chance a kill leaves an item: 35 and 1 a depth, at most 55. */
643export const itemChance = (depth: number) => Math.min(55, 35 + depth)
644
645/** An item that will not fit in the bag, left where it fell (`at`), to be picked up once there is room. */
646const leaveOnFloor = (g: Game, item: Item, at: Pos) => {
647 g.floor = [...(g.floor ?? []), { x: at.x, y: at.y, item }]
648}
649
650/** An item found at `at`: into the bag, said; with the bag full, onto the floor there, said. */
651const stashOrLeave = (g: Game, item: Item, at: Pos) => {
652 if (stash(g, item)) say(g, `Loot: ${item.name} (${RARITY_NAME[item.rarity]}).`)
653 else {
654 leaveOnFloor(g, item, at)
655 say(g, `Your bag is full. The ${item.name} lies where it fell.`)
656 }
657}
658
659/** A prize too good to leave lying (a trophy, a unique's or a boss's, the codex's, work's cache): into the bag, else sent to the
660 * stash in town while it has room, else onto the floor at `at`. Said by `said` with the item named, then where it went. */
661const keepPrize = (g: Game, item: Item, at: Pos, said: (named: string) => string) => {
662 const told = said(`${item.name} (${RARITY_NAME[item.rarity]})`)
663 const tab = whereToStore(g, item)
664 if (stash(g, item)) say(g, told)
665 else if (tab !== undefined) {
666 putIn(g, item, tab)
667 say(g, `${told} Your bag is full: it waits in your stash in town.`)
668 } else if (g.where === 'town') {
669 // Found in town: it waits on the floor of the depth the portal leads back to, by the portal's tile.
670 leaveOnFloor(g, item, g.portal ?? levelOf(g).start)
671 say(g, `${told} Your bag and your stash are full: it waits by the portal in the dungeon.`)
672 } else {
673 leaveOnFloor(g, item, at)
674 say(g, `${told} Your bag and your stash are full: it lies where it fell.`)
675 }
676}
677
678/** Steps onto items lying on the floor: each picked up while there is room, said; what is left, said that it lies there. */
679/** What lies on the hero's tile, into the bag as far as it fits; `isQuiet`, nothing said of what stays. */
680const pickUpFloor = (g: Game, isQuiet = false) => {
681 for (const lying of (g.floor ?? []).filter(f => f.x === g.x && f.y === g.y)) {
682 if (!stash(g, lying.item)) {
683 if (!isQuiet) say(g, `The ${lying.item.name} lies here; your bag is still full.`)
684 continue
685 }
686 g.floor = (g.floor ?? []).filter(f => f !== lying)
687 say(g, `You pick up the ${lying.item.name} (${RARITY_NAME[lying.item.rarity]}).`)
688 }
689}
690
691/**
692 * A walk's first move: what lies on the tile it starts from, into the bag as far as it fits, quietly. Every walk does
693 * it (a click, an explore, an answer finished, a trail) and so does the walk the pane plays, so the two agree.
694 */
695const startWalk = (g: Game) => pickUpFloor(g, true)
696
697/** What a kill drops besides gear (`killConsumable`), onto the belt; none on a barren depth. */
698const dropConsumable = (g: Game, isBarren: boolean) => {
699 const drop = isBarren ? undefined : killConsumable(g)
700 if (drop === undefined) return
701 const name = nameOf(drop.kind, drop.tier)
702 say(g, addToBelt(g, drop.kind, drop.tier) ? `Loot: ${name}.` : `Your belt has no room for the ${name}.`)
703}
704
705/**
706 * What a kill leaves: maybe a potion or scroll, and maybe an item; a champion always leaves one of Uncommon or better,
707 * `champion` its name, which the item's toast gives.
708 */
709const loot = (g: Game, at: Pos, champion?: string) => {
710 const isChampion = champion !== undefined
711 const isBarren = modHere(g) === 'barren'
712 dropConsumable(g, isBarren)
713 // Good loot is never long in coming: the RARE_KILLS-th kill without any leaves a Rare at least.
714 const isRareDue = g.sinceRare >= RARE_KILLS - 1
715 if (!isChampion && !isRareDue && roll(g, 100) > itemChance(g.depth) + (isBarren ? BARREN_ITEMS : 0)) {
716 g.sinceRare += 1
717 return
718 }
719 const item = makeItem(g, isRareDue ? 'rare' : isChampion ? 'uncommon' : 'common', (isChampion ? 2 : 1) * (markHere(g) === 'ironclad' ? 2 : 1))
720 if (item.rarity === 'common' || item.rarity === 'uncommon') g.sinceRare += 1
721 const leave = ordersOf(g).leave
722 if (leave === 'all' || RARITY_RANK.indexOf(item.rarity) < RARITY_RANK.indexOf(leave)) {
723 say(g, `You leave the ${item.name} (${RARITY_NAME[item.rarity]}).`)
724 return
725 }
726 announce(g, item, champion)
727 stashOrLeave(g, item, at)
728}
729
730/** A boss's hoard: the best of three rolls, Rare at least (a big boss's Epic at least), and an Elixir; the way down opens. */
731const bossLoot = (g: Game, foe: Foe, isBig = false) => {
732 const name = foe.name
733 say(g, addToBelt(g, 'health', 3) ? 'Loot: Elixir.' : 'Your belt has no room for the Elixir.')
734 // Half a big boss's Epics, by the depth and the world, are a piece of the hero's class's set.
735 const item = isBig && hash(g.depth, g.world, 41) < SET_SHARE ? setPiece(g, setOf(g.cls)) : makeItem(g, isBig ? 'epic' : 'rare', 3)
736 keepPrize(g, item, foe, named => `Loot: ${named}.`)
737 if (item.rarity !== 'legendary' && item.rarity !== 'epic') g.toast = `The ${name} falls: ${item.name}. The way down is open.`
738 else announce(g, item)
739}
740
741/** Walks the hero up to the foe: false when they fall on the way (a trap), and the fight is over. */
742const charge = (g: Game, foe: Foe) => {
743 const deaths = g.deaths
744 for (const p of (bfs(levelOf(g).tiles, g, foe) ?? []).slice(1, -1)) {
745 moveTo(g, p)
746 if (g.deaths !== deaths) return false
747 }
748 return true
749}
750
751/** Prospector's share more of a kill's gold. */
752const PROSPECTOR = 1.2
753
754/** What a kill pays: gold by the depth and the foe (a boss or unique five times, a champion three), its stolen gold back; XP by its own. The depth's twist and the fortune add theirs. */
755const killPay = (g: Game, foe: Foe) => {
756 const found = roll(g, 3) * g.depth * (foe.boss === true || foe.unique !== undefined ? 5 : foe.champion === undefined ? 1 : 3)
757 const mod = modHere(g)
758 const mark = markHere(g)
759 const paid = Math.floor(found * (mod === 'gloom' ? MOD_MORE : 1) * (mark === 'mild' ? MILD_GOLD : mark === 'lean' ? LEAN_GOLD : 1))
760 const prospected = wornTrigger(g, 'prospector') ? Math.floor(paid * PROSPECTOR) : paid
761 const coins = (g.fortune?.kind === 'riches' ? Math.floor(prospected * FORTUNE_MORE) : prospected) + (foe.stolen ?? 0)
762 const xp = Math.floor(xpGain(g, foe.xp) * (mod === 'horde' ? MOD_MORE : 1))
763 return { coins, xp }
764}
765
766/** What a slain foe leaves: a boss its hoard, a nemesis its trophy, a unique an Epic or better, any other its loot. */
767const spoils = (g: Game, foe: Foe) => {
768 if (foe.boss === true) {
769 g.bossSlain = g.depth
770 bossLoot(g, foe, foe.big !== undefined)
771 announceFork(g)
772 } else if (foe.nemesis === true && g.nemesis !== null) trophy(g, g.nemesis, foe)
773 else if (foe.unique !== undefined) {
774 g.uniquesSlain = [...g.uniquesSlain, bandOf(g.depth)]
775 const item = makeItem(g, 'epic', 2)
776 keepPrize(g, item, foe, named => `${foe.name} is no more. It leaves ${named}.`)
777 g.toast = `${foe.name} slain!`
778 } else loot(g, foe, foe.champion !== undefined ? foe.name : undefined)
779}
780
781/** The foe slain: paid, the class's and the quest's part, its spoils, and a level if it came. */
782const slay = (g: Game, foe: Foe) => {
783 const { coins, xp } = killPay(g, foe)
784 g.foe = null
785 g.xp += xp
786 g.gold += coins
787 say(g, `You slay ${theFoe(foe)}! +${countText(xp)} XP, +${countText(coins)} gold.`)
788 g.tension += KILL_TENSION
789 if (g.cls === 'mage') siphon(g, foe.name)
790 if (wornTrigger(g, 'leeching')) g.hp = Math.min(g.maxHp, g.hp + 1)
791 if (wornTrigger(g, 'momentum')) g.momentum = true
792 if (g.quest?.kind === 'slay' || (g.quest?.kind === 'champion' && foe.champion !== undefined)) advanceQuest(g)
793 spoils(g, foe)
794 loop(g)
795 levelUp(g)
796}
797
798/** The Band of the Endless Loop: every fifth kill while it is worn heals 3 HP. */
799const loop = (g: Game) => {
800 if (wornPower(g, 'loopHeal') === undefined) return
801 const looped = (g.looped ?? 0) + 1
802 g.looped = looped % LOOP_KILLS
803 if (g.looped !== 0) return
804 g.hp = Math.min(g.maxHp, g.hp + LOOP_HEAL)
805 say(g, `The loop comes round: +${LOOP_HEAL} HP.`)
806}
807
808/**
809 * The hero's swing at the foe: a crit by chance, or sure in the rogue's ambush (the first strike of each fight); a
810 * hit of fury doubled, one fewer left; then what the foe's kind makes of it (`heroBlowOn`).
811 */
812const swing = (g: Game, foe: Foe, isSure: boolean) => {
813 const isAmbush = g.cls === 'rogue' && foe.opened === undefined
814 const isChained = g.chain === true && wornPower(g, 'chainCrit') !== undefined
815 const isCrit = roll(g, 100) <= critChance(g) || isAmbush || isSure || isChained
816 // The Whisper of the Linter: a critical makes the next blow one, but a chained one chains no further.
817 delete g.chain
818 if (isCrit && !isChained && wornPower(g, 'chainCrit') !== undefined) g.chain = true
819 const isFury = g.effects.fury > 0
820 if (isFury) g.effects = { ...g.effects, fury: g.effects.fury - 1 }
821 const critTimes = wornPower(g, 'tripleCrit') === undefined ? 2 : 3
822 const blow = heroBlowOn(g, foe, heroDamage(g.level, roll(g, 4), attackBonus(g)) * (isCrit ? critTimes : 1) * (isFury ? 2 : 1))
823 return { blow, isCrit, opened: isAmbush ? openingPlayed(g, foe) : {} }
824}
825
826/** The class's opening played against the foe: done, or with a whole set and the first of two played, braced for the second. */
827const openingPlayed = (g: Game, foe: Foe): Pick<Foe, 'opened' | 'braced'> => (isSetWhole(g) && foe.braced === undefined ? { braced: true } : { opened: true })
828
829/**
830 * Grudge's share more damage to a champion (a nemesis among them) or a unique, and Opener's to a foe at full HP; Bloodletting's
831 * HP from a critical. Simulated from the first step, half again for either took deaths per 1,000 steps from 2.28 to
832 * 1.1 and 1.3; a quarter, to 1.8 and 1.9, with the other talents.
833 */
834const GRUDGE = 1.25
835const OPENER = 1.25
836const BLOODLETTING = 2
837/** Executioner's blow on a foe under a quarter HP; Momentum's after a kill. */
838const EXECUTIONER = 1.5
839const MOMENTUM = 1.25
840
841/** What the hero's worn triggers multiply a blow on `foe` by. */
842const triggerTimes = (g: Game, foe: Foe) =>
843 (wornTrigger(g, 'executioner') && foe.hp * 4 < foe.maxHp ? EXECUTIONER : 1) * (g.momentum === true && wornTrigger(g, 'momentum') ? MOMENTUM : 1)
844
845/** What the hero's talents multiply a blow on `foe` by. */
846const talentTimes = (g: Game, foe: Foe) => {
847 // A nemesis is a champion too.
848 const isGrudge = hasTalent(g, 'grudge') && (foe.champion !== undefined || foe.unique !== undefined)
849 const isOpener = hasTalent(g, 'opener') && foe.hp === foe.maxHp
850 return (isGrudge ? GRUDGE : 1) * (isOpener ? OPENER : 1)
851}
852
853const strike = (g: Game, isSure = false) => {
854 const foe = g.foe
855 if (foe === null || !charge(g, foe)) return
856 if ((foe.shield ?? 0) > 0) {
857 g.foe = { ...foe, shield: (foe.shield ?? 0) - 1 }
858 say(g, `${TheFoe(foe)}'s shield turns your blow.`)
859 return
860 }
861 const { blow, isCrit, opened } = swing(g, foe, isSure)
862 if (blow.said !== undefined) {
863 g.foe = { ...foe, ...opened }
864 say(g, blow.said)
865 return
866 }
867 const dmg = Math.round(blow.dmg * (modHere(g) === 'bloodlust' ? BLOODLUST : 1) * talentTimes(g, foe) * triggerTimes(g, foe))
868 delete g.momentum
869 if (isCrit && hasTalent(g, 'bloodletting')) g.hp = Math.min(g.maxHp, g.hp + BLOODLETTING)
870 const left = foe.hp - dmg
871 const how = isCrit ? 'CRITICALLY hit' : 'hit'
872 if (left > 0) {
873 g.foe = { ...foe, hp: left, ...opened }
874 say(g, `You ${how} ${theFoe(foe)} for ${dmg}.`)
875 return
876 }
877 // Old Bones pulls itself together once, with a third of its HP.
878 if (foe.unique === 'bones' && foe.risen === undefined) {
879 g.foe = { ...foe, hp: Math.ceil(foe.maxHp / 3), risen: true, ...opened }
880 say(g, `You ${how} ${theFoe(foe)} for ${dmg}. It clatters apart... and pulls itself back together!`)
881 return
882 }
883 slay(g, foe)
884}
885
886/** Each class's ability, in a phrase. */
887const CLASS_ABILITY: Readonly<Record<HeroClass, string>> = {
888 knight: "Shield block, a foe's first blow each fight glances off",
889 mage: 'Siphon, each kill restores a fifth of max HP',
890 rogue: 'Ambush, the first strike of each fight is a sure critical',
891}
892const ATTRIBUTE_NAME: Readonly<Record<Attribute, string>> = { might: 'Might', focus: 'Focus', vigor: 'Vigor', insight: 'Insight' }
893
894/** A class in a line, for the choice of a new game: its ability, its stat lean and its starting kit. */
895export const classAbout = (cls: HeroClass) => {
896 const lean = (Object.entries(CLASS_LEAN[cls]) as [Attribute, number][]).map(([k, v]) => `+${v} ${ATTRIBUTE_NAME[k]}`).join(', ')
897 const kit = STARTING_KIT[cls].map(key => baseOf(key)?.name ?? key).join(', ')
898 return `${CLASS_NAME[cls]}: ${CLASS_ABILITY[cls]}. ${lean}. ${kit}.`
899}
900
901/** The mage's siphon: a fifth of max HP back from every kill, a quarter with the whole Archivist's set. */
902const siphon = (g: Game, name: string) => {
903 const healed = Math.min(g.maxHp - g.hp, Math.ceil(g.maxHp / (isSetWhole(g) ? 4 : 5)))
904 if (healed <= 0) return
905 g.hp += healed
906 say(g, `You draw on the fallen ${name}. +${healed} HP`)
907}
908
909/** The foe's blow turned aside: the knight's shield takes the first of each fight, else a ward the next. */
910const isTurned = (g: Game, by: string) => {
911 if (g.cls === 'knight' && g.foe !== null && g.foe.opened === undefined && g.foe.braced === undefined) {
912 g.foe = { ...g.foe, ...openingPlayed(g, g.foe) }
913 say(g, `Your shield turns ${by}'s blow.`)
914 return true
915 }
916 if (g.effects.ward <= 0) return false
917 g.effects = { ...g.effects, ward: g.effects.ward - 1 }
918 say(g, `Your ward absorbs ${by}'s blow.`)
919 return true
920}
921
922/** A goblin's hand in the purse: twice the depth in gold, kept on the foe to give back when it falls. */
923const steal = (g: Game, by: string) => {
924 if (g.foe === null) return
925 const took = Math.min(g.gold, 2 * g.depth)
926 g.gold -= took
927 g.foe = { ...g.foe, stolen: took }
928 if (took > 0) say(g, `${capitalized(by)} snatches ${countText(took)} gold!`)
929}
930
931/** A vampiric champion after its blow: healed by what it dealt, at most half of what it took since its last blow (its
932 * whole loss on its first), so a blow bigger than the hero's never stalls the fight; never past its full HP. */
933const drink = (foe: Foe, dmg: number): Foe => {
934 const taken = (foe.hpAtBlow ?? foe.maxHp) - foe.hp
935 const hp = Math.min(foe.maxHp, foe.hp + Math.min(dmg, Math.max(0, Math.floor(taken / 2))))
936 return { ...foe, hp, hpAtBlow: hp }
937}
938
939const counter = (g: Game, what: string) => {
940 // Who strikes, as a sentence names them: the foe, or with none an imp of the tool.
941 const by = g.foe === null ? `the ${what} imp` : theFoe(g.foe)
942 const turn = g.foe === null ? undefined : foeTurn(g, g.foe)
943 if (turn !== undefined) {
944 g.foe = turn.foe
945 for (const line of turn.said) say(g, line)
946 if (!turn.strikes) return
947 }
948 if (isTurned(g, by)) return
949 const { dmg, halved } = foeBlow(g, turn)
950 say(g, `${capitalized(by)} strikes you for ${dmg}${HALVED_SAID[halved]}.`)
951 g.tension += Math.round((100 * dmg) / g.maxHp)
952 if (turn?.steals === true) steal(g, by)
953 if (g.foe?.champion === 'vampiric') g.foe = drink(g.foe, dmg)
954 hurt(g, dmg, by)
955 landed(g, dmg)
956}
957
958/** What the log adds to a blow halved once or twice. */
959const HALVED_SAID = ['', ', half of it turned', ', three quarters of it turned'] as const
960
961/**
962 * The foe's blow (an imp's with no foe) after the hero's defense, its turn and the depth's twist, halved (rounded up)
963 * for the first of a fight to land with Bulwark worn, and for the knight's braced shield: with the whole Oathbound set
964 * the shield turns the first blow and halves the second, which spends the opening.
965 */
966const foeBlow = (g: Game, turn: ReturnType<typeof foeTurn> | undefined) => {
967 const lust = modHere(g) === 'bloodlust' ? BLOODLUST : 1
968 const blow = Math.max(1, Math.round((g.foe?.atk ?? g.depth) * (turn?.times ?? 1) * lust) - (turn?.pierces === true ? 0 : defense(g)))
969 const isBulwark = g.foe !== null && g.foe.landed === undefined && wornTrigger(g, 'bulwark')
970 const isBraced = g.cls === 'knight' && g.foe?.braced === true && g.foe.opened === undefined
971 if (isBraced && g.foe !== null) g.foe = { ...g.foe, opened: true }
972 const once = isBulwark ? Math.ceil(blow / 2) : blow
973 return { dmg: isBraced ? Math.ceil(once / 2) : once, halved: (isBulwark ? 1 : 0) + (isBraced ? 1 : 0) }
974}
975
976/** A blow landed and the hero standing (a death takes the foe away with it): the foe has landed one, and Thorns answers it. */
977const landed = (g: Game, dmg: number) => {
978 if (g.foe === null) return
979 g.foe = { ...g.foe, landed: true }
980 thorns(g, dmg)
981}
982
983/** Thorns: a foe whose blow landed takes a tenth of it back, rounded up, never the last of its HP. */
984const thorns = (g: Game, dmg: number) => {
985 if (g.foe === null || !wornTrigger(g, 'thorns') || g.foe.hp <= 1) return
986 const back = Math.min(g.foe.hp - 1, Math.ceil(dmg / 10))
987 g.foe = { ...g.foe, hp: g.foe.hp - back }
988 say(g, `Thorns: ${theFoe(g.foe)} takes ${back}.`)
989}
990
991const descend = (g: Game) => {
992 if (g.foe !== null) {
993 say(g, `${TheFoe(g.foe)} guards the stairs.`)
994 return
995 }
996 if (isBossDepth(g.depth) && g.bossSlain < g.depth) {
997 g.foe = isBigBossDepth(g.depth) ? bigBossOf(g) : bossOf(g)
998 say(g, `${TheFoe(g.foe)} (${g.foe.glyph}) rises to bar the way down!`)
999 return
1000 }
1001 const fork = forkTier(g)
1002 // What lies underfoot goes down too, room allowing: the tile a walk down starts from is never stepped onto.
1003 pickUpFloor(g, true)
1004 const behind = g.floor?.length ?? 0
1005 if (behind > 0) say(g, `You leave ${behind === 1 ? 'an item' : `${behind} items`} on the floor behind you.`)
1006 Object.assign(g, arrive(g.world, g.depth + 1), { walked: [], fortune: null, lastSold: null })
1007 g.hp = Math.min(g.maxHp, g.hp + Math.ceil(g.maxHp / 4))
1008 if (g.depth > g.deepest) {
1009 g.deepest = g.depth
1010 g.toast = `New record: depth ${g.depth}.`
1011 }
1012 if (g.quest?.kind === 'depth') advanceQuest(g)
1013 say(g, `Task done. You find stairs > and descend to depth ${g.depth}.`)
1014 enterTier(g, fork)
1015 announceTwist(g)
1016}
1017
1018const attack = (g: Game) => {
1019 if (g.foe === null) {
1020 say(g, 'You swing at the darkness.')
1021 return
1022 }
1023 strike(g)
1024 if (g.foe !== null) counter(g, 'shadow')
1025}
1026
1027const quaff = (g: Game) => {
1028 const slot = healingSlot(g)
1029 say(g, slot < 0 ? 'Your belt holds no healing potions.' : useBelt(g, slot))
1030}
1031
1032const summon = (g: Game, name: string) => {
1033 if (g.foe !== null) return
1034 if (g.calm > 0) {
1035 if (g.calm === CALM_CALLS) say(g, 'The dungeon falls quiet.')
1036 g.calm -= 1
1037 const healed = Math.min(g.maxHp - g.hp, Math.ceil(g.maxHp * CALM_HEAL))
1038 if (healed > 0) {
1039 g.hp += healed
1040 say(g, `You catch your breath. +${healed} HP`)
1041 }
1042 return
1043 }
1044 if (g.nemesis !== null && g.where === 'dungeon' && g.depth >= g.nemesis.depth) {
1045 g.foe = nemesisFoe(g, g.nemesis)
1046 g.toast = `Nemesis: ${nemesisTitle(g.nemesis)}!`
1047 say(g, `You feel watched. ${nemesisTitle(g.nemesis)} has found you!`)
1048 return
1049 }
1050 const band = bandOf(g.depth)
1051 if (g.where === 'dungeon' && g.depth === uniqueDepth(g.world, band) && !g.uniquesSlain.includes(band)) {
1052 g.foe = uniqueFoe(g)
1053 g.toast = `${g.foe.name} is here!`
1054 say(g, `The ground trembles. ${g.foe.name} is here!`)
1055 return
1056 }
1057 const foe = spawn(g, name)
1058 // A peak: the pressure has built, so a champion comes, announced, and a calm follows it.
1059 if (g.tension >= TENSION_PEAK && g.depth >= 2 && g.hp >= g.maxHp * PEAK_HP) {
1060 g.foe = foe.champion !== undefined ? foe : championOf(foe, CHAMPION_MODS[roll(g, CHAMPION_MODS.length) - 1]!)
1061 g.tension = 0
1062 g.calm = CALM_CALLS
1063 g.toast = 'Something strong is near.'
1064 say(g, 'The air grows heavy. Something strong is near.')
1065 } else g.foe = foe
1066 say(g, `A ${g.foe.name} (${g.foe.glyph}) emerges from the dark!`)
1067}
1068
1069/**
1070 * A tool call settles one exchange: a success hits the foe and a foe that survives hits back; a failure only takes the
1071 * foe's hit. Tests that pass make the blow a sure critical; other work is rewarded after (`reward`).
1072 */
1073const outcome = (g: Game, ok: boolean, what: string, work: Work | undefined) => {
1074 if (ok) {
1075 if (work === 'tests') greenBuild(g)
1076 strike(g, work === 'tests')
1077 if (g.foe !== null) counter(g, what)
1078 } else fail(g, what)
1079 const regen = tickEffects(g)
1080 if (regen !== undefined) say(g, regen)
1081 if (ok) reward(g, work)
1082}
1083
1084/** Tests that pass: the blow is sure, and the Aegis of Green Builds wards off the next hit. */
1085const greenBuild = (g: Game) => {
1086 const aegis = wornPower(g, 'greenBlock')
1087 if (aegis !== undefined) {
1088 g.effects = { ...g.effects, ward: Math.max(g.effects.ward, 1) }
1089 say(g, `Your ${aegis.name} hums: the next blow will be turned.`)
1090 }
1091 if (g.foe !== null) say(g, 'The tests pass: your blow is sure.')
1092 if (hasTalent(g, 'testDriven')) {
1093 g.effects = { ...g.effects, fury: g.effects.fury + 2 }
1094 say(g, 'Test driven: 2 blows of fury.')
1095 }
1096 if (wornTrigger(g, 'quickStudy')) {
1097 g.xp += g.depth
1098 say(g, `Quick study: +${g.depth} XP.`)
1099 levelUp(g)
1100 }
1101}
1102
1103/**
1104 * A failure: the foe's hit, or an imp's with no foe. The Staff of Stack Traces strikes the foe first, and a foe it
1105 * slays hits no more; the Mantle of the Monorepo mends 1 HP after.
1106 */
1107const fail = (g: Game, what: string) => {
1108 const staff = wornPower(g, 'stackTrace')
1109 const deaths = g.deaths
1110 if (staff !== undefined && g.foe !== null) {
1111 say(g, `Your ${staff.name} turns the failure on ${theFoe(g.foe)}.`)
1112 strike(g)
1113 if (g.foe !== null) counter(g, what)
1114 } else counter(g, what)
1115 if (hasTalent(g, 'rubberDucking') && g.deaths === deaths) {
1116 g.xp += g.depth
1117 say(g, `Rubber ducking: +${g.depth} XP.`)
1118 levelUp(g)
1119 }
1120 const mantle = wornPower(g, 'failHeal')
1121 if (mantle === undefined || g.deaths !== deaths || g.hp >= g.maxHp) return
1122 g.hp += 1
1123 say(g, `The ${mantle.name} mends 1 HP.`)
1124}
1125
1126/** A commit's shrine: full HP, and the depth marked to wake on, unless a guardian waits on it. */
1127const shrine = (g: Game) => {
1128 g.hp = g.maxHp
1129 if (hasTalent(g, 'cleanCommit')) {
1130 g.effects = { ...g.effects, ward: Math.max(g.effects.ward, 1) }
1131 say(g, 'A clean commit: the next blow will be turned.')
1132 }
1133 if (wornTrigger(g, 'freshStart')) {
1134 g.effects = { ...g.effects, fury: g.effects.fury + 1 }
1135 say(g, 'A fresh start: 1 blow of fury.')
1136 }
1137 if (isBossDepth(g.depth)) {
1138 say(g, 'You kneel at a shrine: your work is kept. Full HP.')
1139 return
1140 }
1141 g.waypoint = g.depth
1142 say(g, `You kneel at a shrine: your work is kept. Full HP; you will wake on depth ${g.depth}.`)
1143 g.toast = `Shrine: you will wake on depth ${g.depth}.`
1144}
1145
1146/** Found by work: an item of `atLeast` or better, best of two rolls, kept as a prize is. */
1147const cache = (g: Game, said: string, atLeast: Rarity) => {
1148 say(g, said)
1149 const item = makeItem(g, atLeast, hasTalent(g, 'shipIt') ? 4 : 2)
1150 keepPrize(g, item, g, named => `Loot: ${named}.`)
1151 g.toast = `${RARITY_NAME[item.rarity]}! ${item.name}`
1152}
1153
1154/** What a commit, a push and a pull request each give, once a depth. */
1155const REWARDS: Readonly<Record<Exclude<Work, 'tests'>, (g: Game) => void>> = {
1156 commit: shrine,
1157 push: g => cache(g, 'Your work goes out, and a cache opens.', 'rare'),
1158 pr: g => cache(g, 'Your pull request opens a hoard.', 'epic'),
1159}
1160
1161/** Work's reward, once a depth for each kind: tests are their blow alone. */
1162const reward = (g: Game, work: Work | undefined) => {
1163 if (work === undefined || work === 'tests' || g.boons?.[work]?.includes(g.depth) === true) return
1164 g.boons = { ...g.boons, [work]: [...(g.boons?.[work] ?? []), g.depth] }
1165 REWARDS[work](g)
1166}
1167
1168const ally = (g: Game) => {
1169 g.hp = Math.min(g.maxHp, g.hp + 3)
1170 say(g, 'A companion joins the fight and patches your wounds. +3 HP')
1171}
1172
1173const move = (g: Game, dx: number, dy: number) => {
1174 const to = { x: g.x + dx, y: g.y + dy }
1175 if (g.foe !== null && same(to, g.foe)) attack(g)
1176 else if (isFloor(levelOf(g).tiles, to)) moveTo(g, to)
1177 else say(g, 'Solid rock.')
1178}
1179
1180/**
1181 * Where the player clicked: a seen floor tile is walked to by the shortest path; a living foe is walked up to and
1182 * attacked; the hero's own tile explores. The walk stops on coming next to a living foe, as exploring does; walking away from one is free.
1183 */
1184/** The way to a clicked tile: to a foe, or to known floor, around a foe in the way where there is room, else up to it. */
1185const wayTo = (g: Game, level: Level, target: Pos, isFoe: boolean) => {
1186 const isKnown = target.x >= 0 && target.y >= 0 && g.seen[cellId(target)] === '1' && isFloor(level.tiles, target)
1187 const around = isKnown && !isFoe && g.foe !== null ? bfs(level.tiles, g, target, g.foe) : null
1188 return around ?? (isFoe || isKnown ? bfs(level.tiles, g, target) : null)
1189}
1190
1191/** Walks the way, stopped by a foe that stands on it or steps up beside the hero, or by a fall: whether it went on to its end. */
1192const walkWay = (g: Game, path: readonly Pos[], isFoe: boolean) => {
1193 const deaths = g.deaths
1194 if (path.length > 1) startWalk(g)
1195 for (const p of path.slice(1, isFoe ? -1 : undefined)) {
1196 if (g.foe !== null && same(p, g.foe)) {
1197 say(g, `${TheFoe(g.foe)} blocks your way.`)
1198 return false
1199 }
1200 const wasNextToFoe = g.foe !== null && isNextTo(g, g.foe)hooks/consumables.ts 186 lines1// Consumables: five kinds, each its own form (potions, herbs, scrolls, runes, bombs) in three tiers, the belt they
2// stack in (a slot a kind), and the effects they leave.
3// Adding a tier or changing one is an entry in CONSUMABLES; a kind has its fixed place on the belt (KINDS).
4import type { BeltSlot, Consumable, Game } from '../types'
5import { pick, roll } from './rng'
6import { TheFoe } from './names'
7import { hasTalent } from './talents'
8import { markHere } from './deep'
9import { HEALING_DROP_SHARE } from './balance'
10
11export const BELT_SIZE = 5
12/** The most of one tier a slot holds. */
13export const STACK_MAX = 99
14/** The kinds in their belt slots, 1 to 5. */
15export const KINDS: readonly Consumable[] = ['health', 'regen', 'fury', 'ward', 'smoke']
16export type Tier = 1 | 2 | 3
17export const TIERS: readonly Tier[] = [1, 2, 3]
18
19const heal = (g: Game, amount: number) => {
20 const before = g.hp
21 g.hp = Math.min(g.maxHp, g.hp + (hasTalent(g, 'ironStomach') ? Math.ceil(amount * 1.25) : amount))
22 return g.hp - before
23}
24
25type Entry = { name: string; about: string; use: (g: Game) => string }
26
27/** The foe left behind in smoke, then what more the tier does; with no foe, only the more. */
28const escape = (g: Game, then: (g: Game) => string) => {
29 if (g.foe === null) return then(g) || 'Smoke drifts away; nothing was hunting you.'
30 const named = TheFoe(g.foe)
31 g.foe = null
32 const more = then(g)
33 return `You vanish in smoke. ${named} loses you.${more === '' ? '' : ` ${more}`}`
34}
35
36const regen = (g: Game, fights: number) => {
37 g.effects = { ...g.effects, regen: Math.max(g.effects.regen, fights) }
38 return `Warmth spreads through you: you heal over the next ${fights} fights.`
39}
40const fury = (g: Game, hits: number) => {
41 g.effects = { ...g.effects, fury: Math.max(g.effects.fury, hits) }
42 return `Fury fills you: your next ${hits} hits deal double damage.`
43}
44const ward = (g: Game, hits: number) => {
45 g.effects = { ...g.effects, ward: Math.max(g.effects.ward, hits) }
46 return `A ward shimmers around you: it absorbs the next ${hits} hits.`
47}
48
49/** Each kind's three tiers: name, what it does in a line (as the merchant's card says), and its use. */
50export const CONSUMABLES: Readonly<Record<Consumable, Readonly<Record<Tier, Entry>>>> = {
51 health: {
52 1: { name: 'Health Potion', about: 'Restores 35% of your HP.', use: g => `You drink a Health Potion. +${heal(g, Math.ceil(g.maxHp * 0.35))} HP` },
53 2: { name: 'Greater Health Potion', about: 'Restores 70% of your HP.', use: g => `You drink a Greater Health Potion. +${heal(g, Math.ceil(g.maxHp * 0.7))} HP` },
54 3: { name: 'Elixir', about: 'Restores all your HP.', use: g => `You drink an Elixir. +${heal(g, g.maxHp)} HP` },
55 },
56 regen: {
57 1: { name: 'Healing Herb', about: 'Heals a tenth of your HP after each of the next 5 fights.', use: g => regen(g, 5) },
58 2: { name: 'Herbal Salve', about: 'Heals a tenth of your HP after each of the next 8 fights.', use: g => regen(g, 8) },
59 3: { name: 'Troll Moss', about: 'Heals a tenth of your HP after each of the next 12 fights.', use: g => regen(g, 12) },
60 },
61 fury: {
62 1: { name: 'Scroll of Fury', about: 'Your next 5 hits deal double damage.', use: g => fury(g, 5) },
63 2: { name: 'Scroll of Battle', about: 'Your next 8 hits deal double damage.', use: g => fury(g, 8) },
64 3: { name: 'Scroll of the Berserker', about: 'Your next 12 hits deal double damage.', use: g => fury(g, 12) },
65 },
66 ward: {
67 1: { name: 'Ward Rune', about: 'Absorbs the next 2 hits.', use: g => ward(g, 2) },
68 2: { name: 'Greater Ward Rune', about: 'Absorbs the next 3 hits.', use: g => ward(g, 3) },
69 3: { name: 'Aegis Rune', about: 'Absorbs the next 5 hits.', use: g => ward(g, 5) },
70 },
71 smoke: {
72 1: { name: 'Smoke Bomb', about: 'Escapes the foe you are fighting.', use: g => escape(g, () => '') },
73 2: { name: 'Shadow Bomb', about: 'Escapes the foe you are fighting, and restores 20% of your HP.', use: g => escape(g, h => `+${heal(h, Math.ceil(h.maxHp * 0.2))} HP`) },
74 3: {
75 name: 'Passage Bomb',
76 about: 'Escapes the foe you are fighting, and reveals the whole level.',
77 use: g => escape(g, h => {
78 h.seen = '1'.repeat(h.seen.length)
79 return 'The whole level is laid bare.'
80 }),
81 },
82 },
83}
84
85export const nameOf = (kind: Consumable, tier: Tier) => CONSUMABLES[kind][tier].name
86
87/** What the merchant sells: a kind at a tier, written as the kind then the tier's number. */
88export type Ware = `${Consumable}${Tier}`
89export const wareOf = (ware: Ware) => ({ kind: ware.slice(0, -1) as Consumable, tier: Number(ware.at(-1)) as Tier })
90
91/** The belt, a slot a kind in KINDS' order, each empty. */
92export const emptyBelt = (): (BeltSlot | null)[] => Array.from({ length: BELT_SIZE }, () => null)
93
94/** How many a slot holds in all its tiers. */
95export const countOf = (slot: BeltSlot | null | undefined) => (slot === null || slot === undefined ? 0 : slot.tiers.reduce((a, b) => a + b, 0))
96/** The strongest tier a slot holds, the one it uses; undefined for an empty slot. */
97export const bestTier = (slot: BeltSlot | null | undefined): Tier | undefined => {
98 if (slot === null || slot === undefined) return undefined
99 for (const t of [3, 2, 1] as const) if ((slot.tiers[t - 1] ?? 0) > 0) return t
100 return undefined
101}
102
103/** The tier a slot uses next: the one chosen for it while it holds one, else its strongest; undefined for an empty slot. */
104export const activeTier = (slot: BeltSlot | null | undefined): Tier | undefined =>
105 slot !== null && slot !== undefined && slot.chosen !== undefined && (slot.tiers[slot.chosen - 1] ?? 0) > 0 ? slot.chosen : bestTier(slot)
106
107/** Chooses which tier a slot uses first; nothing when it holds none of that tier. */
108export const chooseTier = (g: Game, index: number, tier: Tier): string | undefined => {
109 const slot = g.belt[index]
110 if (slot === null || slot === undefined || (slot.tiers[tier - 1] ?? 0) === 0) return `You have no ${slot === null || slot === undefined ? 'such item' : nameOf(slot.kind, tier)} on your belt.`
111 g.belt = g.belt.map((s, i) => (i === index ? { ...slot, chosen: tier } : s))
112 return undefined
113}
114
115/** The belt slot a kind lives in. */
116export const slotOf = (kind: Consumable) => KINDS.indexOf(kind)
117
118/** Whether one more of a kind's tier fits: its slot holds fewer than STACK_MAX of that tier. */
119export const hasRoom = (g: Pick<Game, 'belt'>, kind: Consumable, tier: Tier = 1) => heldOf(g, kind, tier) < STACK_MAX
120
121/** How many of a kind's tier the belt holds. */
122export const heldOf = (g: Pick<Game, 'belt'>, kind: Consumable, tier: Tier) => g.belt[slotOf(kind)]?.tiers[tier - 1] ?? 0
123
124/** One more into its kind's slot, at its tier; false when that tier's stack is full. */
125export const addToBelt = (g: Game, kind: Consumable, tier: Tier = 1): boolean => {
126 if (!hasRoom(g, kind, tier)) return false
127 const at = slotOf(kind)
128 const now = g.belt[at]?.tiers ?? [0, 0, 0]
129 const tiers = now.map((n, i) => (i === tier - 1 ? n + 1 : n)) as [number, number, number]
130 g.belt = g.belt.map((s, i) => (i === at ? { ...s, kind, tiers } : s))
131 return true
132}
133
134/**
135 * Uses a slot: the tier asked for, else the one it uses next (`activeTier`); one less of it, the slot empty once none
136 * is left. A chosen tier used up is forgotten, and the slot goes back to its strongest.
137 */
138export const useBelt = (g: Game, index: number, asked?: Tier): string => {
139 const slot = g.belt[index]
140 const tier = asked ?? activeTier(slot)
141 if (slot === undefined || slot === null || tier === undefined) return `Belt slot ${index + 1} is empty.`
142 if ((slot.tiers[tier - 1] ?? 0) === 0) return `You have no ${nameOf(slot.kind, tier)} on your belt.`
143 const tiers = slot.tiers.map((n, i) => (i === tier - 1 ? n - 1 : n)) as [number, number, number]
144 const { chosen, ...rest } = slot
145 const kept = chosen !== undefined && tiers[chosen - 1]! > 0 ? { ...rest, tiers, chosen } : { ...rest, tiers }
146 g.belt = g.belt.map((s, i) => (i === index ? (tiers.some(n => n > 0) ? kept : null) : s))
147 return CONSUMABLES[slot.kind][tier].use(g)
148}
149
150/** The healing slot when it holds a potion, or -1. */
151export const healingSlot = (g: Game) => (countOf(g.belt[slotOf('health')]) > 0 ? slotOf('health') : -1)
152
153/** One fight's worth of running effects: regeneration heals a tenth of max HP while it lasts. */
154export const tickEffects = (g: Game): string | undefined => {
155 if (g.effects.regen <= 0) return undefined
156 g.effects = { ...g.effects, regen: g.effects.regen - 1 }
157 const healed = heal(g, Math.ceil(g.maxHp / 10))
158 return healed > 0 ? `You regenerate ${healed} HP.` : undefined
159}
160
161/** A tier by depth for a draw `n` of 1 to 100: III 1 in 100 at depth 1, 2 more a depth up to 30; II 4 a depth up to 45 more; else I. */
162export const tierAt = (depth: number, n: number): Tier => {
163 const third = Math.min(30, 1 + 2 * (depth - 1))
164 if (n <= third) return 3
165 if (n <= third + Math.min(45, 4 * depth)) return 2
166 return 1
167}
168
169/** A drop's tier by depth (`tierAt`), rolled. */
170export const tierFor = (g: Game): Tier => tierAt(g.depth, roll(g, 100))
171
172/**
173 * What a kill drops besides gear: a kind and its tier. Healing keeps a steady supply: the chance starts at 40%, falls
174 * 10 points after a potion drops and rises 10 points after a kill without one, and is taken at `HEALING_DROP_SHARE`.
175 */
176export const killConsumable = (g: Game): { kind: Consumable; tier: Tier } | undefined => {
177 if (roll(g, 100) <= g.potionChance * HEALING_DROP_SHARE * (hasTalent(g, 'scavenger') ? 1.5 : 1) * (markHere(g) === 'lean' ? 0.25 : 1)) {
178 g.potionChance = Math.max(10, g.potionChance - 10)
179 return { kind: 'health', tier: tierFor(g) }
180 }
181 g.potionChance = Math.min(90, g.potionChance + 10)
182 if (roll(g, 100) > 15) return undefined
183 const kind = pick(g, g.depth >= 3 ? (['regen', 'fury', 'ward', 'smoke'] as const) : (['regen', 'fury', 'ward'] as const))
184 return { kind, tier: tierFor(g) }
185}
186hooks/frames.ts 119 lines1// Frames: the one ticker every animation plays on, and the measure of how well it keeps time.
2
3/** A spread of frame times in ms: the slowest 1% and the worst say more about stutter than the mean. */
4export type FrameSummary = { count: number; mean: number; p50: number; p99: number; max: number }
5
6/** Nearest-rank percentile `q` (0 to 1) of `sorted`, ascending. */
7const rank = (sorted: readonly number[], q: number) => sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)] ?? 0
8
9export const summarize = (samples: readonly number[]): FrameSummary => {
10 const sorted = [...samples].sort((a, b) => a - b)
11 const sum = sorted.reduce((a, b) => a + b, 0)
12 return { count: sorted.length, mean: sorted.length === 0 ? 0 : sum / sorted.length, p50: rank(sorted, 0.5), p99: rank(sorted, 0.99), max: sorted.at(-1) ?? 0 }
13}
14
15/** The latest samples of some time, kept up to `KEPT`, summarized on demand. */
16export const createSamples = () => {
17 const list: number[] = []
18 return {
19 add: (ms: number) => {
20 list.push(ms)
21 if (list.length > KEPT) list.shift()
22 },
23 summary: () => summarize(list),
24 reset: () => {
25 list.length = 0
26 },
27 }
28}
29
30/** About thirty frames a second. */
31export const FRAME_MS = 33
32/** A tick this much after the one before it came late. */
33export const LATE_MS = FRAME_MS * 1.5
34/** How many of the latest ticks the measure keeps. */
35const KEPT = 600
36
37/** One frame of an animation, `frame` counting from 1; it answers whether the animation goes on. */
38export type Step = (frame: number) => boolean | Promise<boolean>
39/** Starts the shared timer, ticking every FRAME_MS: each tick calls the ticker's `tick` with the time. */
40export type Wake = () => { cancel: () => void }
41export type TickStats = { ticks: number; late: number; skipped: number; interval: FrameSummary; work: FrameSummary }
42
43/**
44 * The single ticker: each tick of one shared timer plays the next frame of every running animation, counted, never
45 * read off the clock, so a late tick plays the next frame rather than skipping ahead. The timer runs only while
46 * something does; the caller owns it, started by the `wake` it hands in, and passes each tick's time. A tick that comes while the last frame is still being drawn plays nothing. `work` times a tick's
47 * frames with `workNow`, finer than the host clock where the runtime has it.
48 */
49export const createTicker = (workNow: () => number = () => 0) => {
50 const running = new Map<string, { frame: number; step: Step }>()
51 let timer: { cancel: () => void } | null = null
52 let lastTick: number | null = null
53 let isBusy = false
54 const intervals: number[] = []
55 const work: number[] = []
56 let ticks = 0
57 let late = 0
58 let skipped = 0
59 const keep = (list: number[], value: number) => {
60 list.push(value)
61 if (list.length > KEPT) list.shift()
62 }
63 const sleepIfIdle = () => {
64 if (running.size > 0 || timer === null) return
65 timer.cancel()
66 timer = null
67 lastTick = null
68 }
69 const tick = async (at: number) => {
70 ticks += 1
71 if (lastTick !== null) {
72 keep(intervals, at - lastTick)
73 if (at - lastTick > LATE_MS) late += 1
74 }
75 lastTick = at
76 if (isBusy) {
77 skipped += 1
78 return
79 }
80 isBusy = true
81 const t0 = workNow()
82 try {
83 for (const [key, animation] of [...running]) {
84 // One started over or stopped by an earlier step this tick plays no more.
85 if (running.get(key) !== animation) continue
86 animation.frame += 1
87 const goesOn = await animation.step(animation.frame)
88 if (!goesOn && running.get(key) === animation) running.delete(key)
89 }
90 } finally {
91 keep(work, workNow() - t0)
92 isBusy = false
93 sleepIfIdle()
94 }
95 }
96 return {
97 /** Starts `key` over from frame 1, replacing any animation by that name. */
98 start: (key: string, step: Step, wake: Wake) => {
99 running.set(key, { frame: 0, step })
100 timer ??= wake()
101 },
102 tick,
103 stop: (key: string) => {
104 running.delete(key)
105 sleepIfIdle()
106 },
107 stats: (): TickStats => ({ ticks, late, skipped, interval: summarize(intervals), work: summarize(work) }),
108 /** Forgets what was measured; the next tick has no gap before it. */
109 reset: () => {
110 intervals.length = 0
111 work.length = 0
112 ticks = 0
113 late = 0
114 skipped = 0
115 lastTick = null
116 },
117 }
118}
119hooks/strip.ts 57 lines1// The strip under the map, as the pane's width draws it: the dash (dash.ts) on a pane DASH_COLUMNS wide or more, the
2// strip of bars (hud.ts) on a narrower one. What the pane asks of the strip, the same whichever it is.
3import type { Game } from '../types'
4import type { Action } from './game'
5import type { Canvas } from './canvas'
6import { clearOf } from './pieces'
7import { DASH_H, DASH_OVER, OVER_ROWS, dashBeltMenu, isOverMap, dashGesture, dashHot, dashLabels, dashOverGesture, dashOverMap, dashStrip, isDash, orbPhase, overLabels } from './dash'
8import { beltMenuBox, hudBoxes, hudGesture, hudHot, hudLayout, hudPixels } from './hud'
9import { drawBox, uncovered } from './window'
10import type { Gesture, OverlayBox } from './window'
11
12export { DASH_OVER, OVER_ROWS, isDash, orbPhase }
13
14/** The strip's height in pixels on a pane `columns` wide. */
15export const stripHeight = (columns: number) => (isDash(columns) ? DASH_H : hudLayout(columns).height)
16
17/** The strip's pixels, the boxes over it (a belt menu) framed on them, the orbs' ripple at `phase`. */
18export const stripPixels = (g: Game, columns: number, boxes: readonly OverlayBox[] = [], phase = 0): Canvas => {
19 if (!isDash(columns)) return hudPixels(g, columns, boxes)
20 const c = dashStrip(g, columns, phase)
21 for (const b of boxes) drawBox(c, b)
22 return c
23}
24
25/** The strip's text boxes: its numbers, less any a box over it covers, then those boxes on top. */
26export const stripBoxes = (g: Game, columns: number, over: readonly OverlayBox[]): OverlayBox[] =>
27 isDash(columns) ? [...clearOf(dashLabels(g, columns), over), ...over] : hudBoxes(g, columns, over)
28
29/** What a click on the strip acts on, in its pixels; what an open belt menu covers left out. */
30export const stripHot = (g: Game, columns: number, boxes: readonly OverlayBox[] = []) =>
31 isDash(columns) ? uncovered(dashHot(g, columns), boxes.filter(b => b.role === 'belt')) : hudHot(g, columns, boxes)
32
33/** A gesture on the strip: the belt menu as it now is, the game actions, and whether the log is asked for. */
34export const stripGesture = (g: Game, menu: number | null, gesture: Gesture, columns: number): { menu: number | null; actions: Action[]; isLog?: true } =>
35 isDash(columns) ? dashGesture(g, menu, gesture, columns) : hudGesture(g, menu, gesture, columns)
36
37/** A belt slot's menu on the strip, or none for an empty slot. */
38export const stripBeltMenu = (g: Game, slot: number, columns: number) => (isDash(columns) ? dashBeltMenu(g, slot, columns) : beltMenuBox(g, slot, columns))
39
40/** What the strip paints over a map `columns` wide: the dash's plaque, crests and the orbs' upper parts; nothing for
41 * the strip of bars. */
42export const stripOverMap = (g: Game, columns: number, phase: number) => (isDash(columns) ? dashOverMap(g, columns, phase) : undefined)
43
44/** The dash's words over the map, for its layer there: the orbs' numbers and the plaque's words. */
45export const overMapBoxes = (g: Game, columns: number) => overLabels(g, columns)
46
47/** The map's pixels the dash hides at its bottom, standing over a map `columns` by `rows` cells: the camera keeps the
48 * hero above them and lets the map run past the level's bottom by them. None without the dash, or on a map too short
49 * for it to stand over. */
50export const mapCovered = (columns: number, rows: number) => (isDash(columns) && rows > OVER_ROWS ? DASH_OVER : 0)
51
52/** A gesture on the dash's layer over the map. */
53export const overMapGesture = dashOverGesture
54
55/** Whether a point of the dash's layer over the map shows the map, not the dash. */
56export const isMapUnder = isOverMap
57hooks/hud.ts 351 lines1// The dashboard strip under the map, all in pixels: the hero's portrait, the HP and XP bars with their numbers, the
2// settings and the portal, the belt, and a line of gold, depth and the running effects; a belt slot's menu of its tiers.
3import type { Game } from '../types'
4import { xpToLevel } from './balance'
5import { depthMod } from './game'
6import type { DepthMod } from './game'
7import { BELT_SLOT_W, BG, GRID, SLOT, SLOT_BG, art, canvas, fill, frame, put } from './canvas'
8import type { Canvas } from './canvas'
9import { COIN_INK, COIN_MARK, CURSOR, artLabel, barLabel, beltCounts, beltSlot, clearOf, flatBar } from './pieces'
10import { TIERS, activeTier, nameOf } from './consumables'
11import type { Tier } from './consumables'
12import type { Action } from './game'
13import { boxShape, drawBox, uncovered } from './window'
14import { countText } from './names'
15import type { Gesture, OverlayBox } from './window'
16import { PAL, PX, TOWN_THING_ART, heroArt } from './pixelart'
17
18export const PORTRAIT = SLOT
19/** The top row beside the portrait: two lines of 5 px text a pixel apart, the bars centered on them. */
20/** The marks stand a pixel past the portrait. */
21export const HEART_X = PORTRAIT + 1
22/** The bars start a pixel past their 5 px marks. */
23export const BAR_X = HEART_X + 6
24/** The marks' tops, and the bars on them, the marks' height: on odd rows of the strip, so each number fills a bar's middle. */
25export const HEART_Y = 1
26export const STAR_Y = 7
27export const HP_Y = HEART_Y
28export const HP_H = 4
29export const LINE2_Y = 7
30export const XP_Y = STAR_Y
31/** Where the belt's row starts, under the top row. */
32/** The belt's row: a pixel under the top row, whose star reaches a pixel below the portrait. */
33export const BELT_BELOW_Y = SLOT + 2
34/** Belt slots share their frame lines. */
35export const SLOT_STEP = BELT_SLOT_W - 1
36/** Gold, depth and the effects, on the belt's row past it: their 5 px marks on the slots' middle, so that their text's
37 * row is a whole cell of the framed strip (its content INSET from the top). */
38export const SIDE_LINE_Y = BELT_BELOW_Y + 3
39export const SIDE_LINE_X = 5 * SLOT_STEP + 1 + 3
40
41/** HP's red, the same at every share as Diablo's life globe: its lit top, its body and its shade. */
42export const HP_TONES = ['#f06a5a', '#c0392b', '#8a231a'] as const
43export const HP_EMPTY = '#1e2420'
44export const XP_FILL = '#e0b84a'
45export const XP_EMPTY = '#2a2618'
46export const NUM_INK = '#e8e4dc'
47export const LEVEL_INK = '#8a8494'
48export const GOLD_INK = '#ffd34d'
49/** The + after the level while stat points wait to be spent. */
50export const POINTS_INK = '#5ccf6a'
51
52/** Small marks, 5 across and 5 down (the heart and the star 4 down): a heart for HP, a star for XP, a coin for gold, steps for depth, one per effect, a crystal ball for a fortune. */
53export const MARK: Readonly<Record<'heart' | 'xp' | 'coin' | 'stairs' | 'regen' | 'ward' | 'fury' | 'fortune' | DepthMod, readonly string[]>> = {
54 // The heart and the star are 4 px tall, and so are their bars, side by side.
55 heart: ['rr.rr', 'rwrrr', '.rrr.', '..r..'],
56 xp: ['..x..', 'xXXXx', '.xXx.', '.x.x.'],
57 coin: COIN_MARK,
58 stairs: ['....s', '...ss', '..sss', '.ssss', 'sssss'],
59 regen: ['..g..', '..g..', 'ggggg', '..g..', '..g..'],
60 ward: ['bbbbb', 'bBbbb', 'bBbbb', '.bbb.', '..b..'],
61 fury: ['..f..', '.ff..', '.fFf.', 'fFFff', '.fff.'],
62 fortune: ['.ccc.', 'cCccc', 'ccccc', '.ccc.', 'nnnnn'],
63 gloom: ['.mm..', 'mm...', 'mm...', 'mm...', '.mm..'],
64 horde: ['h.h.h', 'h.h.h', '.....', 'hhhhh', 'hhhhh'],
65 den: ['y.y.y', 'yyyyy', 'yYyYy', 'yyyyy', '.....'],
66 barren: ['..s..', '.s.s.', 's...s', 's...s', '.sss.'],
67 bloodlust: ['..r..', '.rrr.', 'rrwrr', 'rrrrr', '.rrr.'],
68 hoard: ['.yyy.', 'yYYYy', '.....', '.yyy.', 'yYYYy'],
69}
70export const MARK_INK: Readonly<Record<string, string>> = {
71 ...COIN_INK, r: '#e04a5a', w: '#ffb0b8', s: '#9a9aa4', g: '#5ccf6a', b: '#7fe3ff', B: '#d8f8ff', f: '#f06a5a', F: '#ffd34d', x: '#e0b84a', X: '#fff2a8', c: '#8a6cf0', C: '#e4dcff', n: '#6b6258', m: '#a88ae0', h: '#e08a5a',
72}
73/** The fortune in force, by its initial beside the crystal ball. */
74export const FORTUNE_INITIAL: Readonly<Record<NonNullable<Game['fortune']>['kind'], string>> = { luck: 'L', riches: 'R', insight: 'I' }
75export const mark = (c: Canvas, rows: readonly string[], x: number, y: number) =>
76 rows.forEach((row, dy) => [...row].forEach((k, dx) => k !== '.' && put(c, x + dx, y + dy, MARK_INK[k] ?? '#ff00ff')))
77
78/** The belt's five slots, sharing their frame lines. */
79export const BELT_W = 5 * SLOT_STEP + 1
80
81/** The strip's height: the top row, then the belt and the side line beside it, two pixels of room under them, so the
82 * framed strip is a whole number of cells. */
83export const contentLayout = (_width: number) => ({ height: BELT_BELOW_Y + SLOT + 2 })
84
85/** The portal slot: flush right in the top row. */
86export const portalSlotX = (width: number) => width - SLOT
87/** The settings slot: left of the portal, a pixel apart. */
88export const settingsSlotX = (width: number) => portalSlotX(width) - SLOT - 1
89/**
90 * A gear, for settings, weighing as the portal does: six pixels across in its eight with a pixel of room all round,
91 * eight teeth, its top half lit and its bottom half in shade, symmetric, a dark hole for its axle.
92 */
93const GEAR: readonly string[] = ['........', '.S.SS.S.', '..SSSS..', '.SSKKSS.', '.ssKKss.', '..ssss..', '.s.ss.s.', '........']
94const GEAR_INK: Readonly<Record<string, string>> = { S: '#e4e8f0', s: '#a4acb8', K: '#141219' }
95
96/** The bars' length: from after the marks to a pixel short of the settings slot; their numbers are text on them. */
97export const barsLayout = (_g: Game, width: number) => {
98 const barW = settingsSlotX(width) - 1 - BAR_X
99 return { barW }
100}
101
102/** The iron frame's thickness, the riveted metal band alone, and how far in the contents sit: it and a pixel of room. */
103export const FRAME = 1
104export const INSET = FRAME + 1
105
106/** The framed strip at `width`: its contents' layout, `2 * INSET` narrower, and the whole height with the frame. */
107export const hudLayout = (width: number) => {
108 const inner = contentLayout(width - 2 * INSET)
109 return { ...inner, height: inner.height + 2 * INSET }
110}
111
112/** Where belt slot `i` starts: from the left edge of the belt's row. */
113export const beltSlotX = (_width: number, i: number) => i * SLOT_STEP
114
115const PORTAL_SHUT = '#2e3440'
116
117/** The top row: heart, HP bar and HP; under them a star, the XP bar and the level, a green + after it while points wait. */
118const bars = (c: Canvas, g: Game, width: number) => {
119 const { barW } = barsLayout(g, width)
120 mark(c, MARK.heart, HEART_X, HEART_Y)
121 mark(c, MARK.xp, HEART_X, STAR_Y)
122 const share = Math.max(0, g.hp) / g.maxHp
123 // One flat color, the tone's body: no lit top or shaded bottom.
124 flatBar(c, BAR_X, HP_Y, barW, share, HP_TONES[1], HP_EMPTY)
125 flatBar(c, BAR_X, XP_Y, barW, g.xp / xpToLevel(g.level), XP_FILL, XP_EMPTY)
126}
127
128export const EFFECTS = ['fury', 'ward', 'regen'] as const
129
130/** The side line's text row, in the strip's content pixels: the whole cell on its marks' middle rows. */
131const SIDE_TEXT_Y = SIDE_LINE_Y + 1
132/** A mark's width, and the cell between a mark and its number. */
133const MARK_W = 5
134const MARK_GAP = 1
135
136/** The side line, left to right in pixels from `line.x` to `line.end`: each mark, and each number as text a cell a
137 * character. Gold (in full, thousands set apart, as the title bar writes it) and depth from the left; the running
138 * effects from the right, each left out once it would reach the depth. The strip's is past the belt; the dash's on
139 * its plaque. */
140export const sideLineParts = (g: Game, line: { x: number; end: number }) => {
141 const marks: { rows: readonly string[]; x: number }[] = []
142 const words: { text: string; x: number; ink: string }[] = []
143 const markThen = (rows: readonly string[], x: number, text: string, ink: string) => {
144 marks.push({ rows, x })
145 words.push({ text, x: x + MARK_W + MARK_GAP, ink })
146 return x + MARK_W + MARK_GAP + text.length
147 }
148 const goldEnd = markThen(MARK.coin, line.x, countText(g.gold), GOLD_INK)
149 let depthEnd = markThen(MARK.stairs, goldEnd + 3, g.where === 'town' ? 'TOWN' : String(g.depth), NUM_INK)
150 // The depth's twist, if it has one, right after its number.
151 const mod = g.where === 'dungeon' ? depthMod(g.world, g.depth) : undefined
152 if (mod !== undefined) {
153 marks.push({ rows: MARK[mod], x: depthEnd + 2 })
154 depthEnd += 2 + MARK_W
155 }
156 let right = line.end
157 const fromRight = (rows: readonly string[], text: string) => {
158 const left = right - (MARK_W + MARK_GAP + text.length)
159 if (left < depthEnd + 3) return false
160 markThen(rows, left, text, NUM_INK)
161 right = left - 3
162 return true
163 }
164 if (g.fortune !== null) fromRight(MARK.fortune, FORTUNE_INITIAL[g.fortune.kind])
165 for (const key of EFFECTS) if (g.effects[key] > 0 && !fromRight(MARK[key], String(g.effects[key]))) break
166 return { marks, words }
167}
168
169const sideLine = (c: Canvas, g: Game) => {
170 for (const m of sideLineParts(g, { x: SIDE_LINE_X, end: c.w }).marks) mark(c, m.rows, m.x, SIDE_LINE_Y)
171}
172
173/** The strip, `width` pixels wide and as tall as `contentLayout` says. */
174export const hudContent = (g: Game, width: number): Canvas => {
175 const c = canvas(width, contentLayout(width).height)
176 fill(c, 1, 1, PX, PX, SLOT_BG)
177 frame(c, 0, 0, PORTRAIT, PORTRAIT, GRID)
178 art(c, heroArt(g), 1, 1)
179 bars(c, g, width)
180 const top = BELT_BELOW_Y
181 g.belt.forEach((slot, i) => beltSlot(c, slot, beltSlotX(width, i), top))
182 // The portal, dark while a foe lives and it will not open.
183 const px = portalSlotX(width)
184 frame(c, px, 0, SLOT, SLOT, GRID)
185 fill(c, px + 1, 1, PX, PX, SLOT_BG)
186 const isShut = g.where !== 'town' && g.foe !== null
187 TOWN_THING_ART.portal.forEach((row, dy) => [...row].forEach((k, dx) => {
188 // Shut, it goes grey, its shape kept.
189 if (k !== '.') put(c, px + 1 + dx, 1 + dy, isShut ? PORTAL_SHUT : (PAL[k] ?? '#ff00ff'))
190 }))
191 const sx = settingsSlotX(width)
192 frame(c, sx, 0, SLOT, SLOT, GRID)
193 fill(c, sx + 1, 1, PX, PX, SLOT_BG)
194 GEAR.forEach((row, dy) => [...row].forEach((k, dx) => k !== '.' && put(c, sx + 1 + dx, 1 + dy, GEAR_INK[k] ?? '#ff00ff')))
195 sideLine(c, g)
196 return c
197}
198
199const IRON_LIT = '#5a5f6b'
200const IRON_SHADE = '#3a3e48'
201const RIVET = '#c8ccd4'
202/** Rivets along the frame, this many pixels apart. */
203const RIVET_STEP = 12
204
205/** The iron frame's pixel at x, y of a `w` by `h` strip; undefined inside it. */
206const ironAt = (x: number, y: number, w: number, h: number) => {
207 const sides = { top: y, left: x, bottom: h - 1 - y, right: w - 1 - x }
208 const d = Math.min(sides.top, sides.left, sides.bottom, sides.right)
209 if (d >= FRAME) return undefined
210 const isLit = d === sides.top || d === sides.left
211 const along = d === sides.top || d === sides.bottom ? x : y
212 if (along % RIVET_STEP === RIVET_STEP / 2) return RIVET
213 return isLit ? IRON_LIT : IRON_SHADE
214}
215
216/** The strip in its iron frame: `width` pixels across, the contents INSET from each edge. */
217export const hudPixels = (g: Game, width: number, boxes: readonly OverlayBox[] = []): Canvas => {
218 const inner = hudContent(g, width - 2 * INSET)
219 const h = inner.h + 2 * INSET
220 const c = canvas(width, h)
221 for (let y = 0; y < h; y += 1) for (let x = 0; x < width; x += 1) {
222 const iron = ironAt(x, y, width, h)
223 if (iron !== undefined) put(c, x, y, iron)
224 }
225 inner.px.forEach((row, y) => row.forEach((p, x) => put(c, INSET + x, INSET + y, p)))
226 for (const b of boxes) drawBox(c, b)
227 return c
228}
229
230
231/** What a click on the strip does: `x`, `y` in cells of the strip (a cell is one pixel across, two down). */
232export const hudHit = (width: number, click: { x: number; y: number }): { kind: 'panel'; panel: 'bag' | 'sheet' | 'settings' } | { kind: 'use'; slot: number } | { kind: 'portal' } | undefined => {
233 // Clicks land on the framed strip; the contents start INSET in.
234 const px = Math.floor(click.x) - INSET
235 const py = Math.floor(click.y * 2) - INSET
236 width -= 2 * INSET
237 if (px < 0 || py < 0) return undefined
238 return topHit(px, py, width) ?? beltHit(px, py, width)
239}
240
241type HudAction = ReturnType<typeof hudHit>
242
243/** The strip's top: the portrait for the bag, the portal, the gear for settings, the XP bar's line for the character sheet. */
244const topHit = (px: number, py: number, width: number): HudAction => {
245 if (px < PORTRAIT && py < PORTRAIT) return { kind: 'panel', panel: 'bag' }
246 if (px >= portalSlotX(width) && px < width && py < SLOT) return { kind: 'portal' }
247 if (px >= settingsSlotX(width) && px < settingsSlotX(width) + SLOT && py < SLOT) return { kind: 'panel', panel: 'settings' }
248 if (px >= HEART_X && px < settingsSlotX(width) && py >= LINE2_Y && py < LINE2_Y + HP_H) return { kind: 'panel', panel: 'sheet' }
249 return undefined
250}
251
252/** A belt slot, used. */
253const beltHit = (px: number, py: number, width: number): HudAction => {
254 if (py < BELT_BELOW_Y || py >= BELT_BELOW_Y + SLOT) return undefined
255 for (let slot = 0; slot < 5; slot += 1) {
256 const x = beltSlotX(width, slot)
257 if (px >= x && px < x + BELT_SLOT_W) return { kind: 'use', slot }
258 }
259 return undefined
260}
261
262/** What a click on the strip acts on, in its pixels, as `hudHit` reads them: the portrait, the XP line, the gear, the
263 * portal while it can open, each belt slot holding something, and the side line, which opens the log. What lies
264 * under an open belt menu (among `boxes`) is not lit: a click there lands on the menu. */
265export const hudHot = (g: Game, width: number, boxes: readonly OverlayBox[] = []) => {
266 const w = width - 2 * INSET
267 const at = (x: number, y: number, rw: number, rh: number) => ({ x: INSET + x, y: INSET + y, w: rw, h: rh })
268 return uncovered([
269 at(0, 0, PORTRAIT, PORTRAIT),
270 at(HEART_X, LINE2_Y, settingsSlotX(w) - HEART_X, HP_H),
271 at(settingsSlotX(w), 0, SLOT, SLOT),
272 ...(g.foe === null ? [at(portalSlotX(w), 0, SLOT, SLOT)] : []),
273 ...g.belt.flatMap((s, i) => (s === null ? [] : [at(beltSlotX(w, i), BELT_BELOW_Y, BELT_SLOT_W, SLOT)])),
274 at(SIDE_LINE_X, SIDE_LINE_Y, w - SIDE_LINE_X, SIDE_LINE_H),
275 ], boxes.filter(b => b.role === 'belt'))
276}
277
278/** The strip's text boxes: its numbers, less any a box over it (the belt's menu) covers, then those boxes on top. */
279export const hudBoxes = (g: Game, width: number, over: readonly OverlayBox[]): OverlayBox[] => [
280 ...clearOf(hudLabels(g, width), over),
281 ...over,
282]
283
284/** The belt slot whose menu is open on the strip, or none. */
285export type BeltMenu = number | null
286
287/** The tiers a slot holds, strongest first: what its menu lists. */
288const heldTiers = (g: Game, slot: number): Tier[] => {
289 const s = g.belt[slot]
290 return s === null || s === undefined ? [] : [...TIERS].reverse().filter(t => (s.tiers[t - 1] ?? 0) > 0)
291}
292
293/**
294 * A belt slot's menu, in cells of the strip: a line a tier it holds, strongest first, each its name and how many, the
295 * one the slot uses next marked; a
296 * popover standing on the slot, its left edge on the slot's, just above the belt, kept inside the strip. None for an
297 * empty slot.
298 */
299export const beltMenuBox = (g: Game, slot: number, width: number): OverlayBox<'belt'> | undefined => {
300 const s = g.belt[slot]
301 const tiers = heldTiers(g, slot)
302 if (s === null || s === undefined || tiers.length === 0) return undefined
303 const active = activeTier(s)
304 const box = boxShape('belt', tiers.map(t => [t === active ? { text: '> ', color: CURSOR, bold: true } : { text: ' ' }, { text: nameOf(s.kind, t), bold: t === active }, { text: ` x${s.tiers[t - 1]}`, dim: true }]), CURSOR)
305 return { ...box, x: Math.max(0, Math.min(INSET + beltSlotX(width, slot), width - box.w)), y: Math.max(0, Math.floor((INSET + BELT_BELOW_Y) / 2) - box.h) }
306}
307
308/**
309 * The strip's next menu and the game actions for one gesture: a right-click on a filled belt slot opens its menu; a
310 * click on a line of it chooses that tier for the slot, using nothing; any other click closes it and does what it does
311 * on the strip.
312 */
313/** The side line, gold to the effects, as `hudHit` reads clicks: a click on it opens the log. */
314const SIDE_LINE_H = 5
315const isOnSideLine = (width: number, click: { x: number; y: number }) => {
316 const px = Math.floor(click.x) - INSET
317 const py = Math.floor(click.y * 2) - INSET
318 return px >= SIDE_LINE_X && px < width - 2 * INSET && py >= SIDE_LINE_Y && py < SIDE_LINE_Y + SIDE_LINE_H
319}
320
321/** A gesture on the strip: the belt menu as it now is, the game actions, and whether the log is asked for (the side line). */
322export const hudGesture = (g: Game, menu: BeltMenu, gesture: Gesture, width: number): { menu: BeltMenu; actions: Action[]; isLog?: true } => {
323 if (gesture.type === 'drag') return { menu, actions: [] }
324 const box = menu === null ? undefined : beltMenuBox(g, menu, width)
325 const at = gesture.at
326 if (box !== undefined && menu !== null && at.x >= box.x && at.x < box.x + box.w && at.y >= box.y && at.y < box.y + box.h) {
327 const tier = heldTiers(g, menu)[Math.floor(at.y) - box.y - 1]
328 return tier === undefined ? { menu, actions: [] } : { menu: null, actions: [{ kind: 'choose', slot: menu, tier }] }
329 }
330 if (gesture.button === 'left' && isOnSideLine(width, at)) return { menu: null, actions: [], isLog: true }
331 const hit = hudHit(width, at)
332 if (gesture.button === 'right') return hit?.kind === 'use' && heldTiers(g, hit.slot).length > 0 ? { menu: hit.slot, actions: [] } : { menu: null, actions: [] }
333 return { menu: null, actions: hit === undefined ? [] : [hit] }
334}
335
336/**
337 * The HP and the level as text on their bars, in the strip's cells, like the inventory's cards: the HP on the HP bar's
338 * middle, the level (a + while points wait, in the level's own ink) on the XP bar, each character on the bar's color under it.
339 */
340export const hudLabels = (g: Game, width: number): OverlayBox<'label'>[] => {
341 const { barW } = barsLayout(g, width - 2 * INSET)
342 const share = Math.max(0, g.hp) / g.maxHp
343 const level = `LV${g.level}${g.points > 0 ? '+' : ''}`
344 return [
345 barLabel(INSET + BAR_X, INSET + HP_Y, barW, share, HP_TONES[1], HP_EMPTY, `${Math.max(0, g.hp)}/${g.maxHp}`),
346 barLabel(INSET + BAR_X, INSET + XP_Y, barW, g.xp / xpToLevel(g.level), XP_FILL, XP_EMPTY, level),
347 ...beltCounts(g, i => INSET + beltSlotX(width, i), INSET + BELT_BELOW_Y),
348 ...sideLineParts(g, { x: SIDE_LINE_X, end: width - 2 * INSET }).words.map(w => artLabel(w.text, INSET + w.x, INSET + SIDE_TEXT_Y, w.ink, BG)),
349 ]
350}
351hooks/death.ts 93 lines1// The death window, open once the hero's fall has played: what slew them, drawn, and where; their level and deaths,
2// the gold lost; the depth they wake on; and one button, Wake. Pure: the click layer reports whole gestures, this
3// decides the game actions they make.
4import type { Fallen, Game } from '../types'
5import type { Action, Run } from './game'
6import { themeOf } from './map'
7import { foeArtOf } from './pixelart'
8import { BUTTON_H, PORTRAIT, button, buttonBox, buttonW, portrait } from './pieces'
9import { CLICK_SLOP, CLOSE_BOX, WINDOW_TOP, WINDOW_W, drawBox, isInside, pixel, windowPixels } from './window'
10import type { Chrome, Gesture, OverlayBox, Rect } from './window'
11import { canvas } from './canvas'
12import { countText, theFoe } from './names'
13import { INK, SURFACE } from './ui/tokens'
14
15const CHROME: Chrome = { title: 'YOU DIED' }
16const PAD = 2
17const SIDE_X = PAD + PORTRAIT + 3
18const SLAIN = INK.danger
19const WAKE = { key: 'W', label: 'WAKE' }
20
21/** A trap standing up for its portrait, where the map's lies flat on the floor: two steel spikes, blood on their tips,
22 * shaded down their middles, over its grey plate. */
23const TRAP_PORTRAIT: readonly string[] = [
24 '........',
25 '.r....r.',
26 '.w....w.',
27 'wWw..wWw',
28 'wWw..wWw',
29 'eeeeeeee',
30 'EEEEEEEE',
31 '........',
32]
33
34/** What slew the hero, as the portrait draws it: the foe as it stood on its depth's theme, or the trap. */
35export const killerArt = (f: Fallen): readonly string[] => (f.foe === undefined ? TRAP_PORTRAIT : foeArtOf(f.foe, themeOf(f.depth)))
36
37/** What slew the hero, named: a unique by its own name (it carries any article), any other foe as "the" its name, else as the log said it. */
38export const killerName = (f: Fallen) => (f.foe === undefined ? f.by : theFoe(f.foe))
39
40/** Where everything goes, in window pixels: who and where beside the portrait, then the toll, then Wake. */
41const layout = (g: Game, f: Fallen) => {
42 const top = WINDOW_TOP
43 const who: Run[][] = [[{ text: 'Slain by', color: INK.dim }], [{ text: killerName(f), color: SLAIN, bold: true }], [{ text: `on depth ${f.depth}.`, color: INK.text }]]
44 const whoBox: OverlayBox<'who'> = { role: 'who', x: SIDE_X, y: (top + 2) / 2, w: WINDOW_W - SIDE_X - PAD, h: who.length + 2, border: SURFACE.frame, lines: who }
45 const toll: Run[][] = [
46 [{ text: `Level ${g.level}, deaths ${countText(g.deaths)}. Lost ${countText(f.lost)} gold.`, color: INK.dim }],
47 [{ text: `You wake on depth ${f.wake}.`, color: INK.text }],
48 ]
49 const tollY = Math.max(top + 2 + PORTRAIT, (whoBox.y + whoBox.h) * 2) + 2
50 const tollBox: OverlayBox<'toll'> = { role: 'toll', x: PAD, y: tollY / 2, w: WINDOW_W - 2 * PAD, h: toll.length + 2, border: SURFACE.frame, lines: toll }
51 const wake: Rect = { x: Math.floor((WINDOW_W - buttonW(WAKE.label)) / 2), y: (tollBox.y + tollBox.h) * 2 + 2, w: buttonW(WAKE.label), h: BUTTON_H }
52 // A cell of room under Wake.
53 const height = wake.y + BUTTON_H + 2
54 return { top, whoBox, tollBox, wake, height }
55}
56
57/** The window's height in pixels, for the pane to size its drawing; 0 with no fall to tell. */
58export const deathHeight = (g: Game) => (g.fallen === undefined ? 0 : layout(g, g.fallen).height)
59
60/** The text boxes over the window. */
61export const deathOverlay = (g: Game): OverlayBox[] => {
62 if (g.fallen === undefined) return []
63 const l = layout(g, g.fallen)
64 return [l.whoBox, l.tollBox, buttonBox(WAKE.label, l.wake.x, l.wake.y, 'armed')]
65}
66
67/** The window's pixels: title bar, the killer's portrait, the boxes' frames and the Wake button. */
68export const deathPixels = (g: Game): string[][] => {
69 const f = g.fallen
70 if (f === undefined) return windowPixels(CHROME, []).px
71 const l = layout(g, f)
72 const body = canvas(WINDOW_W, l.height - l.top)
73 portrait(body, killerArt(f), PAD, 2)
74 button(body, WAKE.label, l.wake.x, l.wake.y - l.top, 'armed')
75 const c = windowPixels(CHROME, body.px)
76 drawBox(c, l.whoBox)
77 drawBox(c, l.tollBox)
78 return c.px
79}
80
81/** Wake, by its button or the close box: the window closes. Anything else, nothing. */
82export const deathGesture = (g: Game, gesture: Gesture): Action[] => {
83 if (gesture.type === 'drag' || g.fallen === undefined) return []
84 const from = pixel(gesture.from)
85 const at = pixel(gesture.at)
86 if (Math.max(Math.abs(at.x - from.x), Math.abs(at.y - from.y)) >= CLICK_SLOP) return []
87 const r = layout(g, g.fallen).wake
88 return isInside(at, r.x, r.y, r.w, r.h) || isInside(at, ...CLOSE_BOX) ? [{ kind: 'panel', panel: 'death' }] : []
89}
90
91/** What a click acts on, in window pixels: Wake. */
92export const deathHot = (g: Game): Rect[] => (g.fallen === undefined ? [] : [layout(g, g.fallen).wake])
93hooks/recapwindow.ts 111 lines1// The recap window: the hero's portrait with what the tool calls came to beside it, the best find with its name, and
2// Play on. Pure: the click layer reports whole gestures, this decides the game actions they make.
3import type { Game, Recap } from '../types'
4import type { Action, Run } from './game'
5import { PX, heroArt } from './pixelart'
6import { BUTTON_H, button, buttonBox, buttonW, drawItem, onCell, portrait } from './pieces'
7import { RARITY_COLOR } from './items'
8import { INK, SURFACE } from './ui/tokens'
9import { CLICK_SLOP, CLOSE_BOX, WINDOW_TOP, WINDOW_W, drawBox, isInside, pixel, windowPixels } from './window'
10import type { Chrome, Gesture, OverlayBox, Rect } from './window'
11import { countText } from './names'
12import { canvas } from './canvas'
13import { begun } from './recap'
14
15const CHROME: Chrome = { title: 'WHILE YOU WORKED' }
16const top = WINDOW_TOP
17const PAD = 2
18/** The portrait at twice the sprite's size, the find's box under it, as wide, its frame round the tallest item's art
19 * (20 px); the text beside each. */
20const FACE = PX * 2 + 4
21const FIND_W = FACE
22const FIND_H = FACE + 2
23const SIDE_X = PAD + FACE + 3
24const FIND_X = PAD
25const SLAIN = INK.danger
26const PLAY = { key: 'P', label: 'PLAY ON' }
27/** The find's frame's top: two under the portrait, on a whole cell, so the box beside it starts level with it. */
28const FIND_Y = top + 2 + FACE + 2
29
30const many = (n: number, one: string, more = `${one}s`) => `${countText(n)} ${n === 1 ? one : more}`
31const fromTo = (what: string, from: number, to: number) => (from === to ? `${what} ${to}` : `${what} ${from} to ${to}`)
32
33
34/** The work done, on one line: green test runs, commits, pushes and pull requests; none, no line. */
35const workLine = (r: Recap): Run[][] => {
36 const done = [r.tests > 0 ? many(r.tests, 'green test run') : '', r.commits > 0 ? many(r.commits, 'commit') : '', r.pushes > 0 ? many(r.pushes, 'push', 'pushes') : '', r.prs > 0 ? many(r.prs, 'PR') : ''].filter(t => t !== '')
37 return done.length > 0 ? [[{ text: `${done.join(', ')}.`, color: INK.good }]] : []
38}
39
40const recapOf = (g: Game): Recap => g.recap ?? begun(g)
41
42/** What happened, beside the portrait: the calls, the depth and level, the fighting, the work. */
43const toldLines = (g: Game, r: Recap): Run[][] => [
44 [{ text: `${countText(r.calls)} tool calls`, bold: true }],
45 [{ text: `${fromTo('Depth', r.depth, g.depth)}, ${fromTo('level', r.level, g.level)}.`, ...(g.level > r.level ? { color: INK.good } : {}) }],
46 [
47 { text: `${many(r.kills, 'kill')}${r.champions > 0 ? `, ${many(r.champions, 'champion')}` : ''}, ` },
48 { text: `+${countText(r.gold)} gold`, color: INK.gold },
49 { text: ', ' },
50 { text: many(r.deaths, 'death'), ...(r.deaths > 0 ? { color: SLAIN } : { dim: true }) },
51 { text: '.' },
52 ],
53 ...workLine(r),
54]
55
56/** Play on, centered under the find (under the portrait without one). */
57const playRect = (r: Recap): Rect => {
58 const y = onCell(r.best === undefined ? FIND_Y : FIND_Y + FIND_H + 2)
59 const w = buttonW(PLAY.label)
60 return { x: Math.floor((WINDOW_W - w) / 2), y, w, h: BUTTON_H }
61}
62
63/** The boxes, in cells: what happened beside the portrait; with a find, its name beside its box; Play on's words. */
64export const recapOverlay = (g: Game): OverlayBox[] => {
65 const r = recapOf(g)
66 const told = toldLines(g, r)
67 const play = playRect(r)
68 const box = (lines: Run[][], y: number): OverlayBox => ({ role: 'about', x: SIDE_X, y, w: WINDOW_W - SIDE_X - 2, h: lines.length + 2, border: SURFACE.frame, lines })
69 return [
70 box(told, (top + 2) / 2),
71 ...(r.best === undefined ? [] : [box([[{ text: 'Best find', dim: true }], [{ text: r.best.name, bold: true, color: RARITY_COLOR[r.best.rarity] }]], FIND_Y / 2)]),
72 buttonBox(PLAY.label, play.x, play.y, 'armed'),
73 ]
74}
75
76/** The lines the window says, a line a row of its boxes. */
77export const recapLines = (g: Game): string[] => recapOverlay(g).filter(b => b.role !== 'button').flatMap(b => b.lines.map(l => l.map(r => r.text).join('')))
78
79/** The window's height in pixels, title bar and all. */
80export const recapHeight = (g: Game) => {
81 const play = playRect(recapOf(g))
82 return play.y + play.h + 2
83}
84
85/** The window's pixels: title bar, the hero's portrait, the find, Play on, the boxes. */
86export const recapPixels = (g: Game): string[][] => {
87 const r = recapOf(g)
88 const body = canvas(WINDOW_W, recapHeight(g) - top)
89 portrait(body, heroArt(g), PAD, 2, FACE)
90 // The item's box inside its frame: drawItem frames it a pixel outside.
91 if (r.best !== undefined) drawItem(body, { ...g, cursor: null }, r.best, FIND_X + 1, FIND_Y - top + 1, FIND_W - 2, FIND_H - 2)
92 const play = playRect(r)
93 button(body, PLAY.label, play.x, play.y - top, 'armed')
94 const c = windowPixels(CHROME, body.px)
95 for (const b of recapOverlay(g)) if (b.role !== 'button') drawBox(c, b)
96 return c.px
97}
98
99/** Play on, by its button or the close box: the window closes. Anything else, nothing. */
100export const recapGesture = (g: Game, gesture: Gesture): Action[] => {
101 if (gesture.type === 'drag') return []
102 const from = pixel(gesture.from)
103 const at = pixel(gesture.at)
104 if (Math.max(Math.abs(at.x - from.x), Math.abs(at.y - from.y)) >= CLICK_SLOP) return []
105 const r = playRect(recapOf(g))
106 return isInside(at, r.x, r.y, r.w, r.h) || isInside(at, ...CLOSE_BOX) ? [{ kind: 'panel', panel: 'recap' }] : []
107}
108
109/** What a click acts on, in window pixels: Play on. */
110export const recapHot = (g: Game): Rect[] => [playRect(recapOf(g))]
111hooks/codexwindow.ts 182 lines1// The codex window, the hero's windows' last tab, on two pages: Foes, the uniques and the big bosses; Finds, the
2// Legendaries and Card, which shares the hero. Each is lit once slain or found and a dark shape until then; the box
3// under them counts them, and on Finds holds the records. Pure: the click layer reports whole gestures, this decides
4// the window's page, the game actions they make and whether the card is asked for.
5import type { Codex, Game } from '../types'
6import type { Action, Run } from './game'
7import { BIG_BOSSES, UNIQUES, firstCodex } from './game'
8import { LEGENDARIES, baseOf } from './items'
9import { CELL, ICONS } from './icons'
10import { BOSS_ART, PAL, foeArtOf } from './pixelart'
11import { BUTTON_H, button, buttonBox, buttonW, onCell, tierBox } from './pieces'
12import { BG, SLOT, art, canvas, fill, frame } from './canvas'
13import type { Canvas } from './canvas'
14import { heroChrome, heroTabAt } from './herotabs'
15import { SURFACE } from './ui/tokens'
16import { CLICK_SLOP, CLOSE_BOX, TABS_H, WINDOW_TOP, WINDOW_W, drawBox, isInside, pixel, tabGround, textTabRects, textTabBoxes, windowPixels } from './window'
17import type { Chrome, Gesture, OverlayBox, Rect } from './window'
18import { countText } from './names'
19
20const CHROME: Chrome = { title: 'CODEX' }
21const top = WINDOW_TOP
22const PAD = 3
23const BOX_BG = SURFACE.sunken
24const EDGE = SURFACE.frame
25/** What is not yet slain or found: its shape alone, in one dark ink. */
26const SHADE = SURFACE.shade
27/** A foe's box, its 16 px art and a pixel of frame round it, as the bag's slots; the step from one to the next; a
28 * Legendary's narrower, taller box and step. */
29const FOE_BOX = 18
30const FOE_STEP = 20
31const LEG_H = 22
32const LEG_GAP = 4
33/** Where a row of `n` boxes `w` wide a `step` apart starts, centred in the window (the odd pixel on the right). */
34const rowX = (n: number, w: number, step: number) => Math.floor((WINDOW_W - ((n - 1) * step + w)) / 2)
35/** The pages' tabs at the body's top; the first row of boxes under them, the second under that. */
36const PAGES = [{ label: 'FOES' }, { label: 'FINDS' }] as const
37const ROW1_Y = top + TABS_H + 2
38/** A unique in a slot, as in the bag: its 8 by 8 a dot a pixel in a frame, centred over its big boss's column. */
39const UNIQUE_X = Math.floor((FOE_BOX - SLOT) / 2)
40const ROW2_Y = ROW1_Y + SLOT + 2
41
42/** The window's state beyond the game: the page shown. */
43export type CodexUi = { page: 'foes' | 'finds' }
44export const CODEX_UI: CodexUi = { page: 'foes' }
45const pageIndex = (ui: CodexUi) => (ui.page === 'foes' ? 0 : 1)
46
47/** Art at `scale` pixels a dot, in its own colours or all in `ink`. */
48const scaled = (c: Canvas, rows: readonly string[], x: number, y: number, scale: number, ink?: string) =>
49 rows.forEach((row, dy) =>
50 [...row].forEach((k, dx) => {
51 if (k !== '.') fill(c, x + dx * scale, y + dy * scale, scale, scale, ink ?? PAL[k] ?? SHADE)
52 }),
53 )
54
55const box = (c: Canvas, x: number, y: number, w: number, h: number, isLit: boolean, ground: string = BOX_BG) => {
56 fill(c, x, y, w, h, ground)
57 frame(c, x, y, w, h, isLit ? EDGE : SURFACE.line)
58}
59
60const codexOf = (g: Game): Codex => g.codex ?? firstCodex(g)
61const uniques = Object.entries(UNIQUES)
62const bigs = Object.entries(BIG_BOSSES)
63
64/** The Card button, under the Legendaries on Finds: it saves the hero card and copies it as text. */
65export const CARD_KEY = { key: 'K', label: 'CARD' }
66const CARD_RECT: Rect = { x: Math.floor((WINDOW_W - buttonW(CARD_KEY.label)) / 2), y: onCell(ROW1_Y + LEG_H + 2), w: buttonW(CARD_KEY.label), h: BUTTON_H }
67/** The box under the pages stands 2 px under the lower of the two pages' last rows: Foes' big bosses, Finds' Card (the
68 * two end on one row). */
69const BOX_ABOVE = Math.max(ROW2_Y + FOE_BOX, CARD_RECT.y + BUTTON_H)
70/** The Card button's place, in window pixels, on the page it is on: Finds; none on Foes. */
71export const codexCardRect = (ui: CodexUi = { page: 'finds' }): Rect | undefined => (ui.page === 'finds' ? CARD_RECT : undefined)
72
73const foundLegendaries = (c: Codex) => c.legendaries.filter(n => LEGENDARIES.some(l => l.name === n)).length
74
75/** The box under the page, in cells: on Foes how many of each are slain; on Finds how many Legendaries are found, then
76 * the records. Then the pages' tab labels, and on Finds the Card button's words. */
77export const codexOverlay = (g: Game, ui: CodexUi = CODEX_UI): OverlayBox[] => {
78 const c = codexOf(g)
79 // The page's count, then the records, on both pages, so the box and the window are one height on both.
80 const lines: Run[][] = [
81 ui.page === 'foes' ? [{ text: `Uniques slain ${c.uniques.length} of ${uniques.length}, big bosses ${c.bigs.length} of ${bigs.length}.` }] : [{ text: `Legendaries found ${foundLegendaries(c)} of ${LEGENDARIES.length}.` }],
82 [{ text: `Deepest ${c.deepest}, level ${c.level}, kills ${countText(c.kills)}, bosses ${countText(c.bosses)}.`, dim: true }],
83 [{ text: `Deaths ${countText(c.deaths)}, heroes retired ${c.retired ?? 0}, gold found ${countText(c.gold)}.`, dim: true }],
84 ]
85 const card = ui.page === 'finds' ? [buttonBox(CARD_KEY.label, CARD_RECT.x, CARD_RECT.y, 'ready')] : []
86 return [{ role: 'about', x: 2, y: Math.ceil((BOX_ABOVE + 2) / 2), w: WINDOW_W - 4, h: lines.length + 2, border: SURFACE.frame, lines }, ...textTabBoxes(PAGES, pageIndex(ui), top, BG), ...card]
87}
88
89/** The window's height in pixels, title bar and all. */
90export const codexHeight = (g: Game, ui: CodexUi = CODEX_UI) => {
91 const box = codexOverlay(g, ui)[0]!
92 return 2 * (box.y + box.h) + 2
93}
94
95/** Whether each of a row's entries is lit: the uniques, the big bosses and the Legendaries, in their order. */
96export const codexLit = (g: Game) => {
97 const c = codexOf(g)
98 return {
99 uniques: uniques.map(([, u]) => c.uniques.includes(u.key)),
100 bigs: bigs.map(([, b]) => c.bigs.includes(b.key)),
101 legendaries: LEGENDARIES.map(l => c.legendaries.includes(l.name)),
102 }
103}
104
105const drawFoes = (body: Canvas, lit: ReturnType<typeof codexLit>) => {
106 const x0 = rowX(bigs.length, FOE_BOX, FOE_STEP)
107 uniques.forEach(([theme, one], i) => {
108 const x = x0 + i * FOE_STEP
109 // A unique slain on the map's floor, as it stands there, so its dark outline shows.
110 box(body, x + UNIQUE_X, ROW1_Y - top, SLOT, SLOT, lit.uniques[i]!, lit.uniques[i] ? SURFACE.floor : BOX_BG)
111 scaled(body, foeArtOf({ glyph: one.glyph, unique: one.key }, theme), x + UNIQUE_X + 1, ROW1_Y - top + 1, 1, lit.uniques[i] ? undefined : SHADE)
112 })
113 bigs.forEach(([, one], i) => {
114 const x = x0 + i * FOE_STEP
115 box(body, x, ROW2_Y - top, FOE_BOX, FOE_BOX, lit.bigs[i]!)
116 scaled(body, BOSS_ART[one.key], x + 1, ROW2_Y - top + 1, 1, lit.bigs[i] ? undefined : SHADE)
117 })
118}
119
120/** Each Legendary's art, and its box: the art and a pixel of frame round it across, as in the bag, LEG_H down. */
121const legendaryBoxes = () => {
122 const icons = LEGENDARIES.map(one => ICONS[one.base]?.art ?? [])
123 // Its piece's cells framed, as the bag holds it: w by h cells of CELL, a frame line round them.
124 const sizes = LEGENDARIES.map(one => ({ w: (baseOf(one.base)?.w ?? 1) * CELL + 1, h: (baseOf(one.base)?.h ?? 1) * CELL + 1 }))
125 const across = sizes.reduce((n, s) => n + s.w, 0) + LEG_GAP * (sizes.length - 1)
126 let x = Math.floor((WINDOW_W - across) / 2)
127 return icons.map((icon, i) => {
128 const at = { icon, x, ...sizes[i]! }
129 x += sizes[i]!.w + LEG_GAP
130 return at
131 })
132}
133
134const drawFinds = (body: Canvas, lit: ReturnType<typeof codexLit>) => {
135 const y = ROW1_Y - top
136 legendaryBoxes().forEach(({ icon, x, w, h }, i) => {
137 // Each stands on the row's bottom line. A Legendary found sits in its tier's box, as in the bag; one not yet found
138 // is a shape in a plain one.
139 const top = y + LEG_H - h
140 if (lit.legendaries[i]) tierBox(body, x + 1, top + 1, w - 2, h - 2, 'legendary')
141 else box(body, x, top, w, h, false)
142 art(body, icon, x + 1 + Math.floor((w - 2 - (icon[0]?.length ?? 0)) / 2), top + 1 + Math.floor((h - 2 - icon.length) / 2), lit.legendaries[i] ? undefined : SHADE)
143 })
144 button(body, CARD_KEY.label, CARD_RECT.x, CARD_RECT.y - top, 'ready')
145}
146
147/** The window's pixels: the hero's tabs, the pages' tabs, the page's boxes (and on Finds, Card), the box under them. */
148export const codexPixels = (g: Game, ui: CodexUi = CODEX_UI): string[][] => {
149 const lit = codexLit(g)
150 const body = canvas(WINDOW_W, codexHeight(g, ui) - top)
151 tabGround(body, PAGES, pageIndex(ui), 0)
152 fill(body, PAD, TABS_H, WINDOW_W - 2 * PAD, 1, SURFACE.line)
153 if (ui.page === 'foes') drawFoes(body, lit)
154 else drawFinds(body, lit)
155 const c = windowPixels(heroChrome(g, 'codex'), body.px)
156 for (const b of codexOverlay(g, ui)) if (b.role === 'about') drawBox(c, b)
157 return c.px
158}
159
160/** What a gesture on the codex comes to: the page shown, the game actions, and whether the hero card is asked for. */
161export type CodexOutcome = { ui: CodexUi; actions: Action[]; isCard: boolean }
162
163/** A hero tab opens its window; a page's tab shows it; Card asks for the hero card; the close box closes; anything
164 * else, nothing. */
165export const codexGesture = (g: Game, ui: CodexUi, gesture: Gesture): CodexOutcome => {
166 const none = { ui, actions: [], isCard: false }
167 if (gesture.type === 'drag') return none
168 const from = pixel(gesture.from)
169 const at = pixel(gesture.at)
170 if (Math.max(Math.abs(at.x - from.x), Math.abs(at.y - from.y)) >= CLICK_SLOP) return none
171 const tab = heroTabAt(g, 'codex', at)
172 if (tab !== undefined) return { ...none, actions: tab }
173 const page = textTabRects(PAGES, top).findIndex(r => isInside(at, r.x, r.y, r.w, r.h))
174 if (page >= 0) return { ...none, ui: { page: page === 0 ? 'foes' : 'finds' } }
175 const card = codexCardRect(ui)
176 if (card !== undefined && isInside(at, card.x, card.y, card.w, card.h)) return { ...none, isCard: true }
177 return isInside(at, ...CLOSE_BOX) ? { ...none, actions: [{ kind: 'panel', panel: 'codex' }] } : none
178}
179
180/** What a click acts on, in window pixels: the page not shown, and on Finds Card. */
181export const codexHot = (ui: CodexUi): Rect[] => [...textTabRects(PAGES, top).filter((_, i) => i !== pageIndex(ui)), ...(ui.page === 'finds' ? [CARD_RECT] : [])]
182hooks/herocard.ts 123 lines1// The hero card: the hero as a picture and as a few lines of text, for the player to share where they like. The
2// picture is the portrait in what they wear, who they are and how deep they went, the worn pieces, the codex's counts
3// and the talents taken; written as an SVG, since a plugin writes text, not bytes. Pure: the pane writes the file and
4// copies the text.
5import type { Game } from '../types'
6import { BIG_BOSSES, UNIQUES, firstCodex } from './game'
7import { CLASS_NAME, LEGENDARIES, RARITY_NAME, SLOTS } from './items'
8import { TALENTS } from './talents'
9import { MARKS, markOf, roman, tierOf } from './deep'
10import { heroArt } from './pixelart'
11import { PORTRAIT, drawItem, portrait } from './pieces'
12import { canvas, fill, frame } from './canvas'
13import { word } from './window'
14import { INK, SURFACE } from './ui/tokens'
15import { countText } from './names'
16
17export const CARD_W = 200
18const PAD = 6
19const LINE = 8
20const SIDE_X = PAD + PORTRAIT + 6
21const BOX_W = 13
22const BOX_H = 23
23const BOX_STEP = 14
24const BG = SURFACE.print
25const EDGE = SURFACE.cardEdge
26/** Talent names a card line holds. */
27const TALENTS_A_LINE = 3
28/** The scale the SVG is drawn at: a card pixel this many across. */
29export const CARD_SCALE = 4
30
31const codexOf = (g: Game) => g.codex ?? firstCodex(g)
32const legendariesFound = (g: Game) => codexOf(g).legendaries.filter(n => LEGENDARIES.some(l => l.name === n)).length
33
34/** Where the deepest depth was, and its tier's mark past depth 50. */
35const deepest = (g: Game) => {
36 const tier = tierOf(g.deepest)
37 return tier === 0 ? `${g.deepest}` : `${g.deepest} TIER ${roman(tier)}`
38}
39
40/** The talents taken, upper case, a line each TALENTS_A_LINE; none yet, said. */
41const talentLines = (g: Game) => {
42 const names = (g.talents ?? []).map(t => TALENTS[t].name.toUpperCase())
43 if (names.length === 0) return ['NO TALENTS YET']
44 const lines = []
45 for (let i = 0; i < names.length; i += TALENTS_A_LINE) lines.push(names.slice(i, i + TALENTS_A_LINE).join(' '))
46 return [`TALENTS ${lines[0]}`, ...lines.slice(1)]
47}
48
49/** What the card says beside the portrait, a line each: who, how deep, the kills and deaths, the heroes retired if any. */
50export const cardHead = (g: Game) => {
51 const c = codexOf(g)
52 const retired = c.retired ?? 0
53 return [
54 { text: `LEVEL ${g.level} ${CLASS_NAME[g.cls].toUpperCase()}`, ink: INK.gold },
55 { text: `DEEPEST ${deepest(g)}`, ink: INK.text },
56 { text: `KILLS ${countText(c.kills)} DEATHS ${countText(c.deaths)}`, ink: INK.dim },
57 ...(retired > 0 ? [{ text: `${retired} ${retired === 1 ? 'HERO' : 'HEROES'} RETIRED`, ink: INK.dim }] : []),
58 ]
59}
60
61/** The card's pixels. */
62export const cardPixels = (g: Game): string[][] => {
63 const c = codexOf(g)
64 const talents = talentLines(g)
65 const height = PAD + PORTRAIT + 4 + BOX_H + 5 + 4 + 2 * LINE + 2 + talents.length * LINE + 2 + LINE
66 const body = canvas(CARD_W, height)
67 fill(body, 0, 0, CARD_W, height, BG)
68 portrait(body, heroArt(g), PAD, PAD)
69 cardHead(g).forEach((l, i) => word(body, l.text, SIDE_X, PAD + 1 + i * LINE, l.ink))
70 let y = PAD + PORTRAIT + 4
71 SLOTS.forEach((slot, i) => {
72 const item = g.gear[slot]
73 const x = PAD + i * BOX_STEP
74 if (item === undefined) frame(body, x, y, BOX_W, BOX_H, EDGE)
75 else drawItem(body, g, item, x, y, BOX_W, BOX_H)
76 })
77 y += BOX_H + 5
78 fill(body, PAD, y, CARD_W - 2 * PAD, 1, SURFACE.line)
79 y += 4
80 word(body, `UNIQUES ${c.uniques.length} OF ${Object.keys(UNIQUES).length} BIG BOSSES ${c.bigs.length} OF ${Object.keys(BIG_BOSSES).length}`, PAD, y, INK.text)
81 word(body, `LEGENDARIES ${legendariesFound(g)} OF ${LEGENDARIES.length} GOLD FOUND ${countText(c.gold)}`, PAD, y + LINE, INK.text)
82 y += 2 * LINE + 2
83 talents.forEach((t, i) => word(body, t, PAD, y + i * LINE, INK.dim))
84 y += talents.length * LINE + 2
85 word(body, 'ASCII DUNGEON', PAD, y, EDGE)
86 frame(body, 0, 0, CARD_W, height, EDGE)
87 return body.px
88}
89
90/** The card as text, to paste: who, how deep, the records, the codex's counts, what is worn and the talents. */
91export const cardText = (g: Game): string => {
92 const c = codexOf(g)
93 const tier = tierOf(g.deepest)
94 const where = tier === 0 ? '' : ` (tier ${roman(tier)}, ${MARKS[markOf(g, tier)].name})`
95 const worn = SLOTS.flatMap(slot => (g.gear[slot] === undefined ? [] : [`${g.gear[slot]!.name} (${RARITY_NAME[g.gear[slot]!.rarity]})`]))
96 const talents = (g.talents ?? []).map(t => TALENTS[t].name)
97 return [
98 `ASCII Dungeon: Level ${g.level} ${CLASS_NAME[g.cls]}, deepest depth ${g.deepest}${where}`,
99 `Kills ${countText(c.kills)}, deaths ${countText(c.deaths)}, gold found ${countText(c.gold)}${(c.retired ?? 0) > 0 ? `, heroes retired ${c.retired}` : ''}`,
100 `Uniques ${c.uniques.length} of ${Object.keys(UNIQUES).length}, big bosses ${c.bigs.length} of ${Object.keys(BIG_BOSSES).length}, Legendaries ${legendariesFound(g)} of ${LEGENDARIES.length}`,
101 `Wearing: ${worn.join(', ') || 'nothing'}`,
102 `Talents: ${talents.join(', ') || 'none yet'}`,
103 ].join('\n')
104}
105
106/** Pixels as an SVG, a run of one color along a row a rectangle, each pixel `scale` across. */
107export const svgOf = (px: readonly (readonly string[])[], scale = CARD_SCALE): string => {
108 const h = px.length
109 const w = px[0]?.length ?? 0
110 const rects: string[] = []
111 px.forEach((row, y) => {
112 let x = 0
113 while (x < row.length) {
114 const ink = row[x]!
115 let end = x + 1
116 while (end < row.length && row[end] === ink) end += 1
117 rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${ink}"/>`)
118 x = end
119 }
120 })
121 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">\n${rects.join('\n')}\n</svg>\n`
122}
123hooks/retirewindow.ts 117 lines1// The retire window, laid out as the smith's: what the hero wears on the left, as in the inventory, to choose the
2// heirloom from; on the right the box of what it asks with the chosen piece's name, what passing it down means and who
3// the hero was, and Retire and Back under it. Pure: the click layer reports whole gestures, this decides the window's
4// next state and the game actions they make.
5import type { Game, Slot } from '../types'
6import type { Action, Run } from './game'
7import { whyNotRetire } from './game'
8import { BIG_EVERY } from './map'
9import { CLASS_NAME, RARITY_COLOR, SLOTS } from './items'
10import { WORN_END, WORN_SLOTS, wornSlots } from './inventory'
11import { BUTTON_H, CURSOR, button, buttonBox, buttonW, runX } from './pieces'
12import type { ButtonState } from './pieces'
13import { GRID, canvas, frame } from './canvas'
14import { WORK_X, chosenSlot } from './forge'
15import type { ForgeUi } from './forge'
16import { SPACE, SURFACE } from './ui/tokens'
17import { CLICK_SLOP, CLOSE_BOX, WINDOW_TOP, WINDOW_W, drawBox, isInside, pixel, windowPixels } from './window'
18import type { Chrome, Gesture, OverlayBox, Rect } from './window'
19
20const CHROME: Chrome = { title: 'RETIRE THE HERO' }
21const top = WINDOW_TOP
22const RETIRE = { key: 'R', label: 'RETIRE' }
23const BACK = { key: 'B', label: 'BACK' }
24const ASK = 'Choose an heirloom'
25const TELL = ['Every new hero starts wearing it,', 'its power kept and its stats plain.']
26
27/** The piece the window has chosen: the one picked, else the first worn. */
28export type RetireUi = ForgeUi
29
30const many = (n: number, one: string, more = `${one}s`) => `${n} ${n === 1 ? one : more}`
31const worn = (g: Game) => SLOTS.filter(s => g.gear[s] !== undefined)
32/** A worn slot in window pixels. */
33const slotRect = (slot: Slot): Rect => {
34 const [x, y, w, h] = WORN_SLOTS[slot]
35 return { x, y: top + y, w, h }
36}
37
38/** What it asks with the chosen piece's name in its tier's color, on a line of its own, what passing it down means,
39 * who the hero was. */
40const aboutLines = (g: Game, slot: Slot | undefined): Run[][] => {
41 const item = slot === undefined ? undefined : g.gear[slot]
42 const who = `Level ${g.level} ${CLASS_NAME[g.cls]}, deepest ${g.deepest}, ${many(Math.floor(g.bossSlain / BIG_EVERY), 'big boss', 'big bosses')}.`
43 return [
44 [{ text: item === undefined ? `${ASK}.` : `${ASK}:` }],
45 ...(item === undefined ? [] : [[{ text: item.name, bold: true, color: RARITY_COLOR[item.rarity] }]]),
46 ...TELL.map(text => [{ text }]),
47 [{ text: who, dim: true }],
48 ]
49}
50
51/** Where everything goes, in window pixels: the box beside the worn slots, Retire and Back under it. */
52const layout = (g: Game, ui: RetireUi) => {
53 const slot = chosenSlot(g, ui)
54 const lines = aboutLines(g, slot)
55 const box: OverlayBox = { role: 'about', x: WORK_X, y: (top + 2) / 2, w: WINDOW_W - WORK_X - 1, h: lines.length + 2, border: SURFACE.frame, lines }
56 // Retire and Back, a row centred under the box.
57 const row = buttonW(RETIRE.label) + SPACE.gap + buttonW(BACK.label)
58 const retire: Rect = { x: runX(row, box.x, box.x + box.w), y: (box.y + box.h) * 2 + 2, w: buttonW(RETIRE.label), h: BUTTON_H }
59 const back: Rect = { x: retire.x + retire.w + SPACE.gap, y: retire.y, w: buttonW(BACK.label), h: BUTTON_H }
60 const retireState: ButtonState = slot !== undefined && whyNotRetire(g, slot) === undefined ? 'armed' : 'off'
61 const end = Math.max(top + WORN_END, retire.y + BUTTON_H)
62 // A pixel of room under the last row, as the bag keeps under its belt, on a whole cell.
63 return { slot, box, retire, back, retireState, height: Math.ceil((end + 1) / 2) * 2 }
64}
65
66/** The box beside the worn slots, in cells, then the buttons' words. */
67export const retireOverlay = (g: Game, ui: RetireUi): OverlayBox[] => {
68 const l = layout(g, ui)
69 return [l.box, buttonBox(RETIRE.label, l.retire.x, l.retire.y, l.retireState), buttonBox(BACK.label, l.back.x, l.back.y, 'ready')]
70}
71
72/** The window's height in pixels, title bar and all. */
73export const retireHeight = (g: Game, ui: RetireUi) => layout(g, ui).height
74
75/** Where each worn piece's slot is, in window pixels. */
76export const retireBoxes = (g: Game): ({ slot: Slot } & Rect)[] => worn(g).map(slot => ({ slot, ...slotRect(slot) }))
77
78/** Where Retire and Back are, in window pixels. */
79export const retireButtons = (g: Game, ui: RetireUi) => {
80 const l = layout(g, ui)
81 return { retire: l.retire, back: l.back }
82}
83
84/** The window's pixels: title bar, the worn slots with the chosen one framed white as the smith's, the buttons, the box. */
85export const retirePixels = (g: Game, ui: RetireUi): string[][] => {
86 const l = layout(g, ui)
87 const body = canvas(WINDOW_W, l.height - top)
88 wornSlots(body, g, slot => (slot === l.slot ? CURSOR : GRID))
89 button(body, RETIRE.label, l.retire.x, l.retire.y - top, l.retireState)
90 button(body, BACK.label, l.back.x, l.back.y - top, 'ready')
91 const c = windowPixels(CHROME, body.px)
92 for (const b of retireOverlay(g, ui)) if (b.role !== 'button') drawBox(c, b)
93 return c.px
94}
95
96/** The window's next state and the game actions for one gesture: a piece chosen, Retire, Back to the sheet, the window closed. */
97export const retireGesture = (g: Game, ui: RetireUi, gesture: Gesture): { ui: RetireUi; actions: Action[] } => {
98 const none = { ui, actions: [] }
99 if (gesture.type === 'drag') return none
100 const from = pixel(gesture.from)
101 const at = pixel(gesture.at)
102 if (Math.max(Math.abs(at.x - from.x), Math.abs(at.y - from.y)) >= CLICK_SLOP) return none
103 const l = layout(g, ui)
104 const r = (b: Rect) => isInside(at, b.x, b.y, b.w, b.h)
105 const picked = retireBoxes(g).find(r)
106 if (picked !== undefined) return { ui: { slot: picked.slot }, actions: [] }
107 if (r(l.retire)) return l.slot !== undefined && whyNotRetire(g, l.slot) === undefined ? { ui, actions: [{ kind: 'retire', slot: l.slot }] } : none
108 if (r(l.back)) return { ui, actions: [{ kind: 'panel', panel: 'sheet' }] }
109 return isInside(at, ...CLOSE_BOX) ? { ui, actions: [{ kind: 'panel', panel: 'retire' }] } : none
110}
111
112/** What a click acts on, in window pixels: the worn pieces not chosen, Retire once it can, and Back. */
113export const retireHot = (g: Game, ui: RetireUi): Rect[] => {
114 const l = layout(g, ui)
115 return [...worn(g).filter(s => s !== l.slot).map(slotRect), ...(l.slot !== undefined && whyNotRetire(g, l.slot) === undefined ? [l.retire] : []), l.back]
116}
117