heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files a80ad1c2e997ef7cb60c1375df4ad23cf52f6553
305 lines · 12.5 KB · ~3.7k tok Raw History
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
      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>&gt;</b> descends · <b>&lt;</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)">&gt; &lt;</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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts