heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files a80ad1c2e997ef7cb60c1375df4ad23cf52f6553
| 1 | /** |
| 2 | * lanternfall in a terminal — the same dungeon, the same database, the same |
| 3 | * shared map. `ssh` in and you are another delver on the floor; the web |
| 4 | * players can see you standing there, and you can see them. |
| 5 | * |
| 6 | * hjkl or the arrows move, `.` waits and rests, `>` and `<` take stairs, |
| 7 | * `q` leaves. Your name comes from the account at the keyboard, so the |
| 8 | * gravestone will have it on. |
| 9 | */ |
| 10 | |
| 11 | import * as store from "./db.ts"; |
| 12 | import { HEIGHT, WIDTH, floorAt } from "./dungeon.ts"; |
| 13 | import { act, breedOf, cacheCells, delverFor, isAction, viewFor } from "./game.ts"; |
| 14 | import { ordinal } from "./names.ts"; |
| 15 | import { WELCOME_LINES } from "./pages.ts"; |
| 16 | |
| 17 | const db = store.open(); |
| 18 | const hand = `tty:${process.env.FORGE_APP_USER || "guest"}`; |
| 19 | const esc = "\x1b["; |
| 20 | const out = (s: string): void => { |
| 21 | process.stdout.write(s); |
| 22 | }; |
| 23 | const paint = (code: number, text: string, bold = false): string => |
| 24 | `${esc}${bold ? "1;" : ""}38;5;${code}m${text}${esc}0m`; |
| 25 | |
| 26 | const DARK = 234; |
| 27 | const MEMORY = 239; |
| 28 | const LIT_FLOOR = 137; |
| 29 | const LIT_WALL = 95; |
| 30 | const STAIR = 71; |
| 31 | const GOLD = 178; |
| 32 | const BONE = 183; |
| 33 | const BEAST = 167; |
| 34 | const OTHER = 74; |
| 35 | const YOU = 230; |
| 36 | |
| 37 | function draw(): void { |
| 38 | const delver = delverFor(db, hand); |
| 39 | const view = viewFor(db, delver); |
| 40 | const caches = store.cacheState(db, delver.depth, store.turn(db)); |
| 41 | out(`${esc}2J${esc}H`); |
| 42 | out( |
| 43 | `${paint(GOLD, "lanternfall", true)} ${paint(MEMORY, `· ${ordinal(delver.depth)} floor · turn ${delver.turns}`)}\r\n\r\n`, |
| 44 | ); |
| 45 | for (let y = 0; y < HEIGHT; y += 1) { |
| 46 | let line = " "; |
| 47 | for (let x = 0; x < WIDTH; x += 1) { |
| 48 | const cell = y * WIDTH + x; |
| 49 | const tile = view.floor.tiles[cell] ?? "#"; |
| 50 | const lit = view.visible.has(cell); |
| 51 | const known = view.seen.has(cell); |
| 52 | if (cell === delver.cell) { |
| 53 | line += paint(YOU, "@", true); |
| 54 | continue; |
| 55 | } |
| 56 | if (!lit && !known) { |
| 57 | line += " "; |
| 58 | continue; |
| 59 | } |
| 60 | if (lit) { |
| 61 | const monster = view.monsters.find((m) => m.cell === cell); |
| 62 | if (monster) { |
| 63 | line += paint(BEAST, breedOf(monster.kind).glyph, true); |
| 64 | continue; |
| 65 | } |
| 66 | if (view.others.has(cell)) { |
| 67 | line += paint(OTHER, "@", true); |
| 68 | continue; |
| 69 | } |
| 70 | if (caches.has(cell) === false && view.floor.tiles[cell] === "." && isCache(view, cell)) { |
| 71 | line += paint(GOLD, "$"); |
| 72 | continue; |
| 73 | } |
| 74 | } |
| 75 | if (view.graves.has(cell)) { |
| 76 | line += paint(lit ? BONE : MEMORY, "†"); |
| 77 | continue; |
| 78 | } |
| 79 | if (tile === ">" || tile === "<") { |
| 80 | line += paint(lit ? STAIR : MEMORY, tile, lit); |
| 81 | continue; |
| 82 | } |
| 83 | if (!lit) { |
| 84 | line += paint(MEMORY, tile === "#" ? "#" : "·"); |
| 85 | continue; |
| 86 | } |
| 87 | line += paint(tile === "#" ? LIT_WALL : LIT_FLOOR, tile === "#" ? "#" : "·"); |
| 88 | } |
| 89 | out(`${line}\r\n`); |
| 90 | } |
| 91 | const bar = `${"♥".repeat(Math.max(0, delver.hp))}${paint(DARK, "·".repeat(Math.max(0, delver.max_hp - delver.hp)))}`; |
| 92 | out(`\r\n ${paint(BEAST, bar)} ${paint(GOLD, `${delver.gold}g`)} ${paint(MEMORY, delver.name)}\r\n`); |
| 93 | // What happened this turn in ink; what the log remembers, dim. Before |
| 94 | // anything has happened, the same welcome the page gives. |
| 95 | const heard = store.heard(db, delver.id, 4); |
| 96 | if (heard.length === 0) for (const line of WELCOME_LINES) out(` ${paint(YOU, line)}\r\n`); |
| 97 | for (const line of heard) |
| 98 | out(` ${paint(line.turn === delver.turns ? YOU : MEMORY, line.text)}\r\n`); |
| 99 | out(`\r\n ${paint(DARK, "hjkl/arrows move · . rest · > descend · < climb · q quit")}\r\n`); |
| 100 | } |
| 101 | |
| 102 | // A cache is only a rumour until you are standing next to it; the glyph shows |
| 103 | // where one is *known* to sit, which is what the web page does too. |
| 104 | function isCache(view: ReturnType<typeof viewFor>, cell: number): boolean { |
| 105 | return cacheCellsFor(view.floor.depth).includes(cell); |
| 106 | } |
| 107 | const cacheMemo = new Map<number, number[]>(); |
| 108 | function cacheCellsFor(depth: number): number[] { |
| 109 | const had = cacheMemo.get(depth); |
| 110 | if (had) return had; |
| 111 | const cells = cacheCells(floorAt(depth)); |
| 112 | cacheMemo.set(depth, cells); |
| 113 | return cells; |
| 114 | } |
| 115 | |
| 116 | const KEYS: Record<string, string> = { |
| 117 | h: "west", j: "south", k: "north", l: "east", |
| 118 | [`${esc}A`]: "north", [`${esc}B`]: "south", [`${esc}C`]: "east", [`${esc}D`]: "west", |
| 119 | ".": "wait", " ": "wait", ">": "descend", "<": "ascend", |
| 120 | }; |
| 121 | |
| 122 | function key(data: Buffer): void { |
| 123 | const pressed = data.toString("utf8"); |
| 124 | if (pressed === "q" || pressed === "\x03" || pressed === "\x04") { |
| 125 | out(`${esc}2J${esc}H`); |
| 126 | db.close(); |
| 127 | process.exit(0); |
| 128 | } |
| 129 | const action = KEYS[pressed]; |
| 130 | if (action !== undefined && isAction(action)) act(db, hand, action); |
| 131 | draw(); |
| 132 | } |
| 133 | |
| 134 | if (process.stdin.isTTY) process.stdin.setRawMode(true); |
| 135 | process.stdin.on("data", key); |
| 136 | process.stdout.on("resize", draw); |
| 137 | // Somebody else's turn is your turn too: the floor moves while you stand still. |
| 138 | setInterval(draw, 3000); |
| 139 | draw(); |