SLOPSHOPPER

ASCII Dungeon

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.

newpaneguardcommandtoastprompt
v0.5.0MITupdated 2026-10-26ilovepixelart/ascii-dungeon-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ascii-dungeon
│ ┃ Dungeon ✕ › fix the failing auth test and add an audit log call │ ┃ New game: choose your hero │ ┃ 1: [Knight] 2: Mage 3: Rogue s: Start as ⏺ Read(src/auth.ts) │ ┃ Knight: Shield block, a foe's first blow ⎿ Read 6 lines │ ┃ each fight glances off. +2 Might, +1 Vigor. ⏺ Update(src/auth.ts) │ ┃ Short Sword, Buckler, Tunic, Hood. ⎿ Added 2 lines, removed 1 line │ ┃ Might: harder hits. Focus: crits. Vigor: HP. ⏺ Bash(bun test) │ ┃ Insight: XP. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dungeon │ ⎿ ascii-dungeon: The dungeon pane is open. Click the map to walk a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dungeon
New game: choose your hero 1: [Knight] 2: Mage 3: Rogue s: Start as Knight h: How i Knight: Shield block, a foe's first blow each fight glances off. +2 Might, +1 Vigor. Short Sword, Buckler, Tunic, Hood. Might: harder hits. Focus: crits. Vigor: HP. Insight: XP.
README

ascii-dungeon

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.

The knight in a lit room, the dash below: a red HP orb and a gold XP orb with gargoyle crests, the belt between them, and a plaque of depth and gold

What your coding does

In the sessionIn the dungeon
Every tool callA step or a blow
A failed callThe foe strikes
Tests that passA sure critical
A commitA shrine and full HP
A push or a PRA cache of loot
An answer finishedThe 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.

Install

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.

Playing

/dungeon opens the pane. The pixel view needs a terminal that draws truecolor half blocks, and the mouse needs the fullscreen layout (/tui fullscreen).

PaneShows
105 columns or widerthe map, the strip and every window in pixels
80 to 104 columnsthe map and the strip in pixels, a compact inventory
narrowerthe game as text
62 rows or tallerevery 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.

Working on it

CommandDoes
node scripts/gates.mjs --typecheckevery 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 1000plays games through the rules, every invariant checked each step, and reports balance
bun tools/feel.ts 100 1000danger, 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.tsthe README's pictures, into docs/img/
python3 tools/sounds.pybuilds the sound clips into sounds/ and hooks/clips.ts
python3 tools/measure.pyframes 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

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.

Versioning

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.

License

MIT, see LICENSE.

Source 70 files
hooks/register.tsx 2321 lines
1import { 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 lines
1// 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}
118
hooks/game.ts 2055 lines
1import 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 lines
1// 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}
186
hooks/frames.ts 119 lines
1// 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}
119
hooks/strip.ts 57 lines
1// 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
57
hooks/hud.ts 351 lines
1// 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}
351
hooks/death.ts 93 lines
1// 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])
93
hooks/recapwindow.ts 111 lines
1// 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))]
111
hooks/codexwindow.ts 182 lines
1// 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] : [])]
182
hooks/herocard.ts 123 lines
1// 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}
123
hooks/retirewindow.ts 117 lines
1// 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