/** * 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, 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:last-child { color: var(--ink); } .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 `
`; } const button = (action: string, label: string, extra = ""): string => `
`; export function gamePage( chrome: Chrome, delver: Delver, view: View, caches: Set, log: readonly string[], 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 ? `

You are at the mouth of the dungeon. Your lantern throws about four paces.

` : log.map((line) => `

${escape(line)}

`).join("")}
${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 string[], ): 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.

`}
${log.map((l) => `

${escape(l)}

`).join("")}
`, ); } export function notFoundPage(chrome: Chrome): string { return layout( chrome, "not found — lanternfall", `

There is nothing here.

Back to the dungeon.

`, ); }