/** * The screen. One committed look — a lit room at the bottom of a hole — so * the page paints its own dark ground rather than borrowing the browser's. * * The map is the whole design: lit tiles are warm, remembered tiles are cold * and thin, and everything else is the dark. That difference is the game's * only real information, so nothing else on the page is allowed to be as * bright as the lantern. */ import { DEPTHS, HEIGHT, WIDTH, xy } from "./dungeon.ts"; import { breedOf, type View } from "./game.ts"; import { ordinal } from "./names.ts"; import type { Delver, Grave, Message, Score } from "./db.ts"; export const escape = (s: string): string => s.replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c, ); const STYLE = ` :root { --dark: #0a0b0e; --stone: #14161c; --rule: #242832; --ink: #d9d4c7; --dim: #6d6a63; --memory: #3b414d; --lantern: #e8c07a; --floor-lit: #8a7a5c; --wall-lit: #5d5344; --gold: #e0b341; --blood: #c4513f; --bone: #b9a8d4; --stair: #6fae86; --you: #f3ece0; color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; background: var(--dark); color: var(--ink); font: 15px/1.6 ui-monospace, "SF Mono", Menlo, monospace; padding: 1.25rem 1rem 3rem; } .wrap { max-width: 63rem; margin: 0 auto; display: flex; flex-direction: column; gap: 1.1rem; } header { display: flex; flex-wrap: wrap; gap: .6rem 1.4rem; align-items: baseline; } h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; margin: 0; color: var(--lantern); } h1 a { color: inherit; text-decoration: none; } .tag { color: var(--dim); font-size: .82rem; } a { color: var(--stair); } .board { display: flex; flex-wrap: wrap; gap: 1.1rem; align-items: flex-start; } .glass { flex: 1 1 32rem; min-width: 0; background: #06070a; border: 1px solid var(--rule); border-radius: 3px; padding: .7rem; overflow-x: auto; } .map { display: grid; grid-template-columns: repeat(${WIDTH}, 1ch); line-height: 1.05; font-size: clamp(9px, 2.1vw, 15px); white-space: pre; } .map span { display: block; text-align: center; } .u { color: #0d0e12; } /* never seen */ .m { color: var(--memory); } /* remembered */ .f { color: var(--floor-lit); } /* lit floor */ .w { color: var(--wall-lit); } /* lit wall */ .s { color: var(--stair); font-weight: 700; } .c { color: var(--gold); } .g { color: var(--bone); } .e { color: var(--blood); font-weight: 700; } .o { color: #7fa7c4; font-weight: 700; } .y { color: var(--you); font-weight: 700; text-shadow: 0 0 6px var(--lantern); } aside { flex: 1 1 15rem; display: flex; flex-direction: column; gap: 1rem; min-width: 13rem; } .panel { background: var(--stone); border: 1px solid var(--rule); border-radius: 3px; padding: .8rem .9rem; } .panel h2 { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--dim); margin: 0 0 .5rem; font-weight: 600; } .stat { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; } .stat b { font-weight: 600; color: var(--ink); } .hp { height: 7px; background: #241a18; border-radius: 2px; overflow: hidden; margin: .45rem 0 .2rem; } .hp div { height: 100%; background: linear-gradient(90deg, var(--blood), #d98b5f); } .entry { font-size: .82rem; color: var(--dim); padding: .3rem 0; border-top: 1px solid var(--rule); } .entry:first-of-type { border-top: 0; } .entry b { color: var(--bone); font-weight: 600; } .entry i { color: var(--gold); font-style: normal; } .log { background: #06070a; border: 1px solid var(--rule); border-radius: 3px; padding: .7rem .9rem; min-height: 6.5rem; display: flex; flex-direction: column; justify-content: flex-end; gap: .1rem; } .log p { margin: 0; font-size: .86rem; color: var(--dim); } .log p.now { color: var(--ink); } .log p.now::before { content: "▸ "; color: var(--lantern); } .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } .pad { display: grid; grid-template-columns: repeat(3, 3rem); grid-template-rows: repeat(2, 3rem); gap: .35rem; } form.inline { display: contents; } button { font: inherit; min-height: 44px; min-width: 3rem; padding: .4rem .7rem; cursor: pointer; color: var(--ink); background: #171a21; border: 1px solid var(--rule); border-radius: 3px; } button:hover { border-color: var(--lantern); color: var(--lantern); } button:focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; } .pad .n { grid-column: 2; grid-row: 1; } .pad .w { grid-column: 1; grid-row: 2; } .pad .s { grid-column: 2; grid-row: 2; } .pad .e { grid-column: 3; grid-row: 2; } .others { display: flex; flex-wrap: wrap; gap: .4rem; } .keys { color: var(--dim); font-size: .78rem; } .legend { display: flex; flex-wrap: wrap; gap: .4rem 1rem; color: var(--dim); font-size: .78rem; } .dead { text-align: center; padding: 2rem 1rem; } .dead h2 { color: var(--blood); letter-spacing: .1em; font-size: 1rem; } .dead p { color: var(--dim); max-width: 34rem; margin: .5rem auto; } footer { color: var(--dim); font-size: .76rem; border-top: 1px solid var(--rule); padding-top: .8rem; } `; export interface Chrome { env: string; pull: string | undefined; commit: string; ssh: string | undefined; } const where = (c: Chrome): string => c.env === "preview" ? `preview of pull #${c.pull ?? "?"}` : c.env; function layout(chrome: Chrome, title: string, body: string): string { return ` ${escape(title)}

lanternfall

one dungeon · five floors · everyone's map
${body}
`; } /** The dungeon as the delver currently understands it. */ function mapOf(view: View, delver: Delver, caches: Set): string { const cells: string[] = []; for (let cell = 0; cell < WIDTH * HEIGHT; cell += 1) { const tile = view.floor.tiles[cell] ?? "#"; const lit = view.visible.has(cell); const known = view.seen.has(cell); let glyph = tile === "#" ? "#" : tile === "." ? "·" : tile; let cls = lit ? (tile === "#" ? "w" : "f") : known ? "m" : "u"; if (!lit && !known) { cells.push(` `); continue; } if (tile === ">" || tile === "<") cls = lit ? "s" : "m"; // Things are only there when you can see them; the map remembers rooms, // not what is standing in them. if (lit) { if (caches.has(cell)) { glyph = "$"; cls = "c"; } const monster = view.monsters.find((m) => m.cell === cell); if (monster) { glyph = breedOf(monster.kind).glyph; cls = "e"; } else if (view.others.has(cell)) { glyph = "@"; cls = "o"; } } if (view.graves.has(cell) && (lit || known)) { glyph = "†"; cls = lit ? "g" : "m"; } if (cell === delver.cell) { glyph = "@"; cls = "y"; } cells.push(`${glyph}`); } return `
`; } /** * What a stranger reads before anything has happened to them: where they * are, what they are here for, and the two rules the buttons do not explain. * It stays until the dungeon has something of its own to say. */ export const WELCOME_LINES: readonly string[] = [ "You are at the mouth of the dungeon, standing on the way out. Your lantern throws about four paces.", "The gold is below. It is yours only once you climb back out through this stair; die and it stays where you fell.", "Walk into a thing to fight it. Wait to heal, which works only when nothing can see you.", ]; const WELCOME = WELCOME_LINES.map((line) => `

${escape(line)}

`).join(""); /** The log, with what happened this turn told apart from what is remembered. */ const logOf = (log: readonly Message[], turn: number): string => log .map((line) => `${escape(line.text)}

`) .join(""); const button = (action: string, label: string, extra = ""): string => `
`; export function gamePage( chrome: Chrome, delver: Delver, view: View, caches: Set, log: readonly Message[], graves: readonly Grave[], scores: readonly Score[], known: number, ): string { const tile = view.floor.tiles[delver.cell]; const stairs = (tile === ">" ? button("descend", "descend ›") : "") + (tile === "<" ? button("ascend", delver.depth === 1 ? "climb out ↑" : "climb up ↑") : ""); return layout( chrome, `floor ${delver.depth} — lanternfall`, `
${mapOf(view, delver, caches)}
${log.length === 0 ? WELCOME : logOf(log, delver.turns)}
${button("north", "↑", "n")}${button("west", "←", "w")}${button("south", "↓", "s")}${button("east", "→", "e")}
${button("wait", "wait / rest")} ${stairs}

arrows or wasd move · . waits · > descends · < climbs

@ you@ another delver r g G S W what lives here$ a cache † a grave> < stairs

`, ); } export function endPage( chrome: Chrome, delver: Delver, climbedOut: boolean, log: readonly Message[], ): string { return layout( chrome, climbedOut ? "climbed out — lanternfall" : "died — lanternfall", `

${climbedOut ? "You climbed out." : "You died."}

${escape(delver.name)} ${climbedOut ? `banked ${delver.gold} gold` : `fell on the ${ordinal(delver.depth)} floor with ${delver.gold} gold`} after ${delver.turns} turns.

${delver.epitaph ? `

${escape(delver.epitaph)}

` : ""} ${climbedOut ? "" : `

Your grave is where you fell, and whoever finds it takes half of what you were carrying.

`}
${logOf(log, delver.turns)}
`, ); } export function notFoundPage(chrome: Chrome): string { return layout( chrome, "not found — lanternfall", `

There is nothing here.

Back to the dungeon.

`, ); }