heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files 8c843521bbc603b656573701a58a86c57edd8cbc
| 1 | /** |
| 2 | * The screen. One committed look — a lit room at the bottom of a hole — so |
| 3 | * the page paints its own dark ground rather than borrowing the browser's. |
| 4 | * |
| 5 | * The map is the whole design: lit tiles are warm, remembered tiles are cold |
| 6 | * and thin, and everything else is the dark. That difference is the game's |
| 7 | * only real information, so nothing else on the page is allowed to be as |
| 8 | * bright as the lantern. |
| 9 | */ |
| 10 | |
| 11 | import { DEPTHS, HEIGHT, WIDTH, xy } from "./dungeon.ts"; |
| 12 | import { breedOf, type View } from "./game.ts"; |
| 13 | import { ordinal } from "./names.ts"; |
| 14 | import type { Delver, Grave, Message, Score } from "./db.ts"; |
| 15 | |
| 16 | export const escape = (s: string): string => |
| 17 | s.replace( |
| 18 | /[&<>"']/g, |
| 19 | (c) => |
| 20 | ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? |
| 21 | c, |
| 22 | ); |
| 23 | |
| 24 | const STYLE = ` |
| 25 | :root { |
| 26 | --dark: #0a0b0e; |
| 27 | --stone: #14161c; |
| 28 | --rule: #242832; |
| 29 | --ink: #d9d4c7; |
| 30 | --dim: #6d6a63; |
| 31 | --memory: #3b414d; |
| 32 | --lantern: #e8c07a; |
| 33 | --floor-lit: #8a7a5c; |
| 34 | --wall-lit: #5d5344; |
| 35 | --gold: #e0b341; |
| 36 | --blood: #c4513f; |
| 37 | --bone: #b9a8d4; |
| 38 | --stair: #6fae86; |
| 39 | --you: #f3ece0; |
| 40 | color-scheme: dark; |
| 41 | } |
| 42 | * { box-sizing: border-box; } |
| 43 | body { |
| 44 | margin: 0; background: var(--dark); color: var(--ink); |
| 45 | font: 15px/1.6 ui-monospace, "SF Mono", Menlo, monospace; |
| 46 | padding: 1.25rem 1rem 3rem; |
| 47 | } |
| 48 | .wrap { max-width: 63rem; margin: 0 auto; display: flex; flex-direction: column; gap: 1.1rem; } |
| 49 | header { display: flex; flex-wrap: wrap; gap: .6rem 1.4rem; align-items: baseline; } |
| 50 | h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; margin: 0; color: var(--lantern); } |
| 51 | h1 a { color: inherit; text-decoration: none; } |
| 52 | .tag { color: var(--dim); font-size: .82rem; } |
| 53 | a { color: var(--stair); } |
| 54 | |
| 55 | .board { display: flex; flex-wrap: wrap; gap: 1.1rem; align-items: flex-start; } |
| 56 | .glass { flex: 1 1 32rem; min-width: 0; background: #06070a; border: 1px solid var(--rule); border-radius: 3px; padding: .7rem; overflow-x: auto; } |
| 57 | .map { display: grid; grid-template-columns: repeat(${WIDTH}, 1ch); line-height: 1.05; font-size: clamp(9px, 2.1vw, 15px); white-space: pre; } |
| 58 | .map span { display: block; text-align: center; } |
| 59 | .u { color: #0d0e12; } /* never seen */ |
| 60 | .m { color: var(--memory); } /* remembered */ |
| 61 | .f { color: var(--floor-lit); } /* lit floor */ |
| 62 | .w { color: var(--wall-lit); } /* lit wall */ |
| 63 | .s { color: var(--stair); font-weight: 700; } |
| 64 | .c { color: var(--gold); } |
| 65 | .g { color: var(--bone); } |
| 66 | .e { color: var(--blood); font-weight: 700; } |
| 67 | .o { color: #7fa7c4; font-weight: 700; } |
| 68 | .y { color: var(--you); font-weight: 700; text-shadow: 0 0 6px var(--lantern); } |
| 69 | |
| 70 | aside { flex: 1 1 15rem; display: flex; flex-direction: column; gap: 1rem; min-width: 13rem; } |
| 71 | .panel { background: var(--stone); border: 1px solid var(--rule); border-radius: 3px; padding: .8rem .9rem; } |
| 72 | .panel h2 { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--dim); margin: 0 0 .5rem; font-weight: 600; } |
| 73 | .stat { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; } |
| 74 | .stat b { font-weight: 600; color: var(--ink); } |
| 75 | .hp { height: 7px; background: #241a18; border-radius: 2px; overflow: hidden; margin: .45rem 0 .2rem; } |
| 76 | .hp div { height: 100%; background: linear-gradient(90deg, var(--blood), #d98b5f); } |
| 77 | .entry { font-size: .82rem; color: var(--dim); padding: .3rem 0; border-top: 1px solid var(--rule); } |
| 78 | .entry:first-of-type { border-top: 0; } |
| 79 | .entry b { color: var(--bone); font-weight: 600; } |
| 80 | .entry i { color: var(--gold); font-style: normal; } |
| 81 | |
| 82 | .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; } |
| 83 | .log p { margin: 0; font-size: .86rem; color: var(--dim); } |
| 84 | .log p.now { color: var(--ink); } |
| 85 | .log p.now::before { content: "▸ "; color: var(--lantern); } |
| 86 | |
| 87 | .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } |
| 88 | .pad { display: grid; grid-template-columns: repeat(3, 3rem); grid-template-rows: repeat(2, 3rem); gap: .35rem; } |
| 89 | form.inline { display: contents; } |
| 90 | button { |
| 91 | font: inherit; min-height: 44px; min-width: 3rem; padding: .4rem .7rem; cursor: pointer; |
| 92 | color: var(--ink); background: #171a21; border: 1px solid var(--rule); border-radius: 3px; |
| 93 | } |
| 94 | button:hover { border-color: var(--lantern); color: var(--lantern); } |
| 95 | button:focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; } |
| 96 | .pad .n { grid-column: 2; grid-row: 1; } |
| 97 | .pad .w { grid-column: 1; grid-row: 2; } |
| 98 | .pad .s { grid-column: 2; grid-row: 2; } |
| 99 | .pad .e { grid-column: 3; grid-row: 2; } |
| 100 | .others { display: flex; flex-wrap: wrap; gap: .4rem; } |
| 101 | .keys { color: var(--dim); font-size: .78rem; } |
| 102 | .legend { display: flex; flex-wrap: wrap; gap: .4rem 1rem; color: var(--dim); font-size: .78rem; } |
| 103 | .dead { text-align: center; padding: 2rem 1rem; } |
| 104 | .dead h2 { color: var(--blood); letter-spacing: .1em; font-size: 1rem; } |
| 105 | .dead p { color: var(--dim); max-width: 34rem; margin: .5rem auto; } |
| 106 | footer { color: var(--dim); font-size: .76rem; border-top: 1px solid var(--rule); padding-top: .8rem; } |
| 107 | `; |
| 108 | |
| 109 | export interface Chrome { |
| 110 | env: string; |
| 111 | pull: string | undefined; |
| 112 | commit: string; |
| 113 | ssh: string | undefined; |
| 114 | } |
| 115 | |
| 116 | const where = (c: Chrome): string => |
| 117 | c.env === "preview" ? `preview of pull #${c.pull ?? "?"}` : c.env; |
| 118 | |
| 119 | function layout(chrome: Chrome, title: string, body: string): string { |
| 120 | return `<!doctype html> |
| 121 | <meta charset="utf-8"> |
| 122 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 123 | <title>${escape(title)}</title> |
| 124 | <style>${STYLE}</style> |
| 125 | <div class="wrap"> |
| 126 | <header> |
| 127 | <h1><a href="/">lanternfall</a></h1> |
| 128 | <span class="tag">one dungeon · five floors · everyone's map</span> |
| 129 | </header> |
| 130 | ${body} |
| 131 | <footer> |
| 132 | <p>${escape(where(chrome))}${chrome.commit ? ` · ${escape(chrome.commit)}` : ""}${chrome.ssh ? ` · terminal: ${escape(chrome.ssh)}` : ""}</p> |
| 133 | <p>The floors are generated from a seed, so they are the same for everyone and always will be. Only what has happened is written down.</p> |
| 134 | </footer> |
| 135 | </div> |
| 136 | <script src="/static/keys.v1.js" defer></script> |
| 137 | `; |
| 138 | } |
| 139 | |
| 140 | /** The dungeon as the delver currently understands it. */ |
| 141 | function mapOf(view: View, delver: Delver, caches: Set<number>): string { |
| 142 | const cells: string[] = []; |
| 143 | for (let cell = 0; cell < WIDTH * HEIGHT; cell += 1) { |
| 144 | const tile = view.floor.tiles[cell] ?? "#"; |
| 145 | const lit = view.visible.has(cell); |
| 146 | const known = view.seen.has(cell); |
| 147 | let glyph = tile === "#" ? "#" : tile === "." ? "·" : tile; |
| 148 | let cls = lit ? (tile === "#" ? "w" : "f") : known ? "m" : "u"; |
| 149 | if (!lit && !known) { |
| 150 | cells.push(`<span class="u"> </span>`); |
| 151 | continue; |
| 152 | } |
| 153 | if (tile === ">" || tile === "<") cls = lit ? "s" : "m"; |
| 154 | // Things are only there when you can see them; the map remembers rooms, |
| 155 | // not what is standing in them. |
| 156 | if (lit) { |
| 157 | if (caches.has(cell)) { |
| 158 | glyph = "$"; |
| 159 | cls = "c"; |
| 160 | } |
| 161 | const monster = view.monsters.find((m) => m.cell === cell); |
| 162 | if (monster) { |
| 163 | glyph = breedOf(monster.kind).glyph; |
| 164 | cls = "e"; |
| 165 | } else if (view.others.has(cell)) { |
| 166 | glyph = "@"; |
| 167 | cls = "o"; |
| 168 | } |
| 169 | } |
| 170 | if (view.graves.has(cell) && (lit || known)) { |
| 171 | glyph = "†"; |
| 172 | cls = lit ? "g" : "m"; |
| 173 | } |
| 174 | if (cell === delver.cell) { |
| 175 | glyph = "@"; |
| 176 | cls = "y"; |
| 177 | } |
| 178 | cells.push(`<span class="${cls}">${glyph}</span>`); |
| 179 | } |
| 180 | return `<div class="glass"><div class="map" role="img" aria-label="the dungeon floor, drawn in ascii">${cells.join("")}</div></div>`; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * What a stranger reads before anything has happened to them: where they |
| 185 | * are, what they are here for, and the two rules the buttons do not explain. |
| 186 | * It stays until the dungeon has something of its own to say. |
| 187 | */ |
| 188 | export const WELCOME_LINES: readonly string[] = [ |
| 189 | "You are at the mouth of the dungeon, standing on the way out. Your lantern throws about four paces.", |
| 190 | "The gold is below. It is yours only once you climb back out through this stair; die and it stays where you fell.", |
| 191 | "Walk into a thing to fight it. Wait to heal, which works only when nothing can see you.", |
| 192 | ]; |
| 193 | const WELCOME = WELCOME_LINES.map((line) => `<p class="now">${escape(line)}</p>`).join(""); |
| 194 | |
| 195 | /** The log, with what happened this turn told apart from what is remembered. */ |
| 196 | const logOf = (log: readonly Message[], turn: number): string => |
| 197 | log |
| 198 | .map((line) => `<p${line.turn === turn ? ' class="now"' : ""}>${escape(line.text)}</p>`) |
| 199 | .join(""); |
| 200 | |
| 201 | const button = (action: string, label: string, extra = ""): string => |
| 202 | `<form class="inline" method="post" action="/act"><button class="${extra}" name="action" value="${action}">${label}</button></form>`; |
| 203 | |
| 204 | export function gamePage( |
| 205 | chrome: Chrome, |
| 206 | delver: Delver, |
| 207 | view: View, |
| 208 | caches: Set<number>, |
| 209 | log: readonly Message[], |
| 210 | graves: readonly Grave[], |
| 211 | scores: readonly Score[], |
| 212 | known: number, |
| 213 | ): string { |
| 214 | const tile = view.floor.tiles[delver.cell]; |
| 215 | const stairs = |
| 216 | (tile === ">" ? button("descend", "descend ›") : "") + |
| 217 | (tile === "<" ? button("ascend", delver.depth === 1 ? "climb out ↑" : "climb up ↑") : ""); |
| 218 | return layout( |
| 219 | chrome, |
| 220 | `floor ${delver.depth} — lanternfall`, |
| 221 | `<div class="board"> |
| 222 | ${mapOf(view, delver, caches)} |
| 223 | <aside> |
| 224 | <div class="panel"> |
| 225 | <h2>${escape(delver.name)}</h2> |
| 226 | <div class="stat"><span>health</span><b>${delver.hp} / ${delver.max_hp}</b></div> |
| 227 | <div class="hp"><div style="width:${Math.max(0, Math.round((delver.hp / delver.max_hp) * 100))}%"></div></div> |
| 228 | <div class="stat"><span>carrying</span><b>${delver.gold} gold</b></div> |
| 229 | <div class="stat"><span>depth</span><b>${ordinal(delver.depth)} floor</b></div> |
| 230 | <div class="stat"><span>turns</span><b>${delver.turns}</b></div> |
| 231 | </div> |
| 232 | <div class="panel"> |
| 233 | <h2>the dead</h2> |
| 234 | ${graves.length === 0 ? `<p class="entry">Nobody yet.</p>` : graves |
| 235 | .slice(0, 5) |
| 236 | .map( |
| 237 | (g) => |
| 238 | `<p class="entry"><b>${escape(g.name)}</b> · floor ${g.depth} · <i>${g.gold}g</i><br>${escape(g.epitaph)}</p>`, |
| 239 | ) |
| 240 | .join("")} |
| 241 | </div> |
| 242 | <div class="panel"> |
| 243 | <h2>climbed out</h2> |
| 244 | ${scores.length === 0 ? `<p class="entry">Nobody has come back up yet.</p>` : scores |
| 245 | .slice(0, 5) |
| 246 | .map( |
| 247 | (s) => `<p class="entry"><b>${escape(s.name)}</b> · <i>${s.gold}g</i> · floor ${s.depth}</p>`, |
| 248 | ) |
| 249 | .join("")} |
| 250 | </div> |
| 251 | <div class="panel"> |
| 252 | <h2>the dungeon remembers</h2> |
| 253 | <div class="stat"><span>tiles lit, ever</span><b>${known}</b></div> |
| 254 | <div class="stat"><span>floors</span><b>${DEPTHS}</b></div> |
| 255 | </div> |
| 256 | </aside> |
| 257 | </div> |
| 258 | |
| 259 | <div class="log">${log.length === 0 ? WELCOME : logOf(log, delver.turns)}</div> |
| 260 | |
| 261 | <div class="controls"> |
| 262 | <div class="pad"> |
| 263 | ${button("north", "↑", "n")}${button("west", "←", "w")}${button("south", "↓", "s")}${button("east", "→", "e")} |
| 264 | </div> |
| 265 | <div class="others"> |
| 266 | ${button("wait", "wait / rest")} |
| 267 | ${stairs} |
| 268 | </div> |
| 269 | <p class="keys">arrows or wasd move · <b>.</b> waits · <b>></b> descends · <b><</b> climbs</p> |
| 270 | </div> |
| 271 | <p class="legend"> |
| 272 | <span><b class="y">@</b> you</span><span><b style="color:#7fa7c4">@</b> another delver</span> |
| 273 | <span><b style="color:var(--blood)">r g G S W</b> what lives here</span><span><b style="color:var(--gold)">$</b> a cache</span> |
| 274 | <span><b style="color:var(--bone)">†</b> a grave</span><span><b style="color:var(--stair)">> <</b> stairs</span> |
| 275 | </p>`, |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | export function endPage( |
| 280 | chrome: Chrome, |
| 281 | delver: Delver, |
| 282 | climbedOut: boolean, |
| 283 | log: readonly Message[], |
| 284 | ): string { |
| 285 | return layout( |
| 286 | chrome, |
| 287 | climbedOut ? "climbed out — lanternfall" : "died — lanternfall", |
| 288 | `<div class="dead"> |
| 289 | <h2>${climbedOut ? "You climbed out." : "You died."}</h2> |
| 290 | <p>${escape(delver.name)} ${climbedOut ? `banked ${delver.gold} gold` : `fell on the ${ordinal(delver.depth)} floor with ${delver.gold} gold`} after ${delver.turns} turns.</p> |
| 291 | ${delver.epitaph ? `<p>${escape(delver.epitaph)}</p>` : ""} |
| 292 | ${climbedOut ? "" : `<p>Your grave is where you fell, and whoever finds it takes half of what you were carrying.</p>`} |
| 293 | <div class="log" style="max-width:34rem;margin:1.2rem auto;">${logOf(log, delver.turns)}</div> |
| 294 | <form method="post" action="/act"><button name="action" value="wait">send someone else down</button></form> |
| 295 | </div>`, |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | export function notFoundPage(chrome: Chrome): string { |
| 300 | return layout( |
| 301 | chrome, |
| 302 | "not found — lanternfall", |
| 303 | `<div class="dead"><h2>There is nothing here.</h2><p><a href="/">Back to the dungeon.</a></p></div>`, |
| 304 | ); |
| 305 | } |