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

workbenchthis machine
Files fa8aea72e9cab5ed2a004bcd8bfa540e643d8b9d
286 lines · 11.7 KB · ~3.4k 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, 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:last-child { color: var(--ink); }
85
86
  .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
87
  .pad { display: grid; grid-template-columns: repeat(3, 3rem); grid-template-rows: repeat(2, 3rem); gap: .35rem; }
88
  form.inline { display: contents; }
89
  button {
90
    font: inherit; min-height: 44px; min-width: 3rem; padding: .4rem .7rem; cursor: pointer;
91
    color: var(--ink); background: #171a21; border: 1px solid var(--rule); border-radius: 3px;
92
  }
93
  button:hover { border-color: var(--lantern); color: var(--lantern); }
94
  button:focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; }
95
  .pad .n { grid-column: 2; grid-row: 1; }
96
  .pad .w { grid-column: 1; grid-row: 2; }
97
  .pad .s { grid-column: 2; grid-row: 2; }
98
  .pad .e { grid-column: 3; grid-row: 2; }
99
  .others { display: flex; flex-wrap: wrap; gap: .4rem; }
100
  .keys { color: var(--dim); font-size: .78rem; }
101
  .legend { display: flex; flex-wrap: wrap; gap: .4rem 1rem; color: var(--dim); font-size: .78rem; }
102
  .dead { text-align: center; padding: 2rem 1rem; }
103
  .dead h2 { color: var(--blood); letter-spacing: .1em; font-size: 1rem; }
104
  .dead p { color: var(--dim); max-width: 34rem; margin: .5rem auto; }
105
  footer { color: var(--dim); font-size: .76rem; border-top: 1px solid var(--rule); padding-top: .8rem; }
106
`;
107
108
export interface Chrome {
109
  env: string;
110
  pull: string | undefined;
111
  commit: string;
112
  ssh: string | undefined;
113
}
114
115
const where = (c: Chrome): string =>
116
  c.env === "preview" ? `preview of pull #${c.pull ?? "?"}` : c.env;
117
118
function layout(chrome: Chrome, title: string, body: string): string {
119
  return `<!doctype html>
120
<meta charset="utf-8">
121
<meta name="viewport" content="width=device-width, initial-scale=1">
122
<title>${escape(title)}</title>
123
<style>${STYLE}</style>
124
<div class="wrap">
125
<header>
126
  <h1><a href="/">lanternfall</a></h1>
127
  <span class="tag">one dungeon · five floors · everyone's map</span>
128
</header>
129
${body}
130
<footer>
131
  <p>${escape(where(chrome))}${chrome.commit ? ` · ${escape(chrome.commit)}` : ""}${chrome.ssh ? ` · terminal: ${escape(chrome.ssh)}` : ""}</p>
132
  <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>
133
</footer>
134
</div>
135
<script src="/static/keys.v1.js" defer></script>
136
`;
137
}
138
139
/** The dungeon as the delver currently understands it. */
140
function mapOf(view: View, delver: Delver, caches: Set<number>): string {
141
  const cells: string[] = [];
142
  for (let cell = 0; cell < WIDTH * HEIGHT; cell += 1) {
143
    const tile = view.floor.tiles[cell] ?? "#";
144
    const lit = view.visible.has(cell);
145
    const known = view.seen.has(cell);
146
    let glyph = tile === "#" ? "#" : tile === "." ? "·" : tile;
147
    let cls = lit ? (tile === "#" ? "w" : "f") : known ? "m" : "u";
148
    if (!lit && !known) {
149
      cells.push(`<span class="u"> </span>`);
150
      continue;
151
    }
152
    if (tile === ">" || tile === "<") cls = lit ? "s" : "m";
153
    // Things are only there when you can see them; the map remembers rooms,
154
    // not what is standing in them.
155
    if (lit) {
156
      if (caches.has(cell)) {
157
        glyph = "$";
158
        cls = "c";
159
      }
160
      const monster = view.monsters.find((m) => m.cell === cell);
161
      if (monster) {
162
        glyph = breedOf(monster.kind).glyph;
163
        cls = "e";
164
      } else if (view.others.has(cell)) {
165
        glyph = "@";
166
        cls = "o";
167
      }
168
    }
169
    if (view.graves.has(cell) && (lit || known)) {
170
      glyph = "†";
171
      cls = lit ? "g" : "m";
172
    }
173
    if (cell === delver.cell) {
174
      glyph = "@";
175
      cls = "y";
176
    }
177
    cells.push(`<span class="${cls}">${glyph}</span>`);
178
  }
179
  return `<div class="glass"><div class="map" role="img" aria-label="the dungeon floor, drawn in ascii">${cells.join("")}</div></div>`;
180
}
181
182
const button = (action: string, label: string, extra = ""): string =>
183
  `<form class="inline" method="post" action="/act"><button class="${extra}" name="action" value="${action}">${label}</button></form>`;
184
185
export function gamePage(
186
  chrome: Chrome,
187
  delver: Delver,
188
  view: View,
189
  caches: Set<number>,
190
  log: readonly string[],
191
  graves: readonly Grave[],
192
  scores: readonly Score[],
193
  known: number,
194
): string {
195
  const tile = view.floor.tiles[delver.cell];
196
  const stairs =
197
    (tile === ">" ? button("descend", "descend ›") : "") +
198
    (tile === "<" ? button("ascend", delver.depth === 1 ? "climb out ↑" : "climb up ↑") : "");
199
  return layout(
200
    chrome,
201
    `floor ${delver.depth} — lanternfall`,
202
    `<div class="board">
203
${mapOf(view, delver, caches)}
204
<aside>
205
  <div class="panel">
206
    <h2>${escape(delver.name)}</h2>
207
    <div class="stat"><span>health</span><b>${delver.hp} / ${delver.max_hp}</b></div>
208
    <div class="hp"><div style="width:${Math.max(0, Math.round((delver.hp / delver.max_hp) * 100))}%"></div></div>
209
    <div class="stat"><span>carrying</span><b>${delver.gold} gold</b></div>
210
    <div class="stat"><span>depth</span><b>${ordinal(delver.depth)} floor</b></div>
211
    <div class="stat"><span>turns</span><b>${delver.turns}</b></div>
212
  </div>
213
  <div class="panel">
214
    <h2>the dead</h2>
215
    ${graves.length === 0 ? `<p class="entry">Nobody yet.</p>` : graves
216
      .slice(0, 5)
217
      .map(
218
        (g) =>
219
          `<p class="entry"><b>${escape(g.name)}</b> · floor ${g.depth} · <i>${g.gold}g</i><br>${escape(g.epitaph)}</p>`,
220
      )
221
      .join("")}
222
  </div>
223
  <div class="panel">
224
    <h2>climbed out</h2>
225
    ${scores.length === 0 ? `<p class="entry">Nobody has come back up yet.</p>` : scores
226
      .slice(0, 5)
227
      .map(
228
        (s) => `<p class="entry"><b>${escape(s.name)}</b> · <i>${s.gold}g</i> · floor ${s.depth}</p>`,
229
      )
230
      .join("")}
231
  </div>
232
  <div class="panel">
233
    <h2>the dungeon remembers</h2>
234
    <div class="stat"><span>tiles lit, ever</span><b>${known}</b></div>
235
    <div class="stat"><span>floors</span><b>${DEPTHS}</b></div>
236
  </div>
237
</aside>
238
</div>
239
240
<div class="log">${log.length === 0 ? `<p>You are at the mouth of the dungeon. Your lantern throws about four paces.</p>` : log.map((line) => `<p>${escape(line)}</p>`).join("")}</div>
241
242
<div class="controls">
243
  <div class="pad">
244
    ${button("north", "↑", "n")}${button("west", "←", "w")}${button("south", "↓", "s")}${button("east", "→", "e")}
245
  </div>
246
  <div class="others">
247
    ${button("wait", "wait / rest")}
248
    ${stairs}
249
  </div>
250
  <p class="keys">arrows or wasd move · <b>.</b> waits · <b>&gt;</b> descends · <b>&lt;</b> climbs</p>
251
</div>
252
<p class="legend">
253
  <span><b class="y">@</b> you</span><span><b style="color:#7fa7c4">@</b> another delver</span>
254
  <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>
255
  <span><b style="color:var(--bone)">†</b> a grave</span><span><b style="color:var(--stair)">&gt; &lt;</b> stairs</span>
256
</p>`,
257
  );
258
}
259
260
export function endPage(
261
  chrome: Chrome,
262
  delver: Delver,
263
  climbedOut: boolean,
264
  log: readonly string[],
265
): string {
266
  return layout(
267
    chrome,
268
    climbedOut ? "climbed out — lanternfall" : "died — lanternfall",
269
    `<div class="dead">
270
  <h2>${climbedOut ? "You climbed out." : "You died."}</h2>
271
  <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>
272
  ${delver.epitaph ? `<p>${escape(delver.epitaph)}</p>` : ""}
273
  ${climbedOut ? "" : `<p>Your grave is where you fell, and whoever finds it takes half of what you were carrying.</p>`}
274
  <div class="log" style="max-width:34rem;margin:1.2rem auto;">${log.map((l) => `<p>${escape(l)}</p>`).join("")}</div>
275
  <form method="post" action="/act"><button name="action" value="wait">send someone else down</button></form>
276
</div>`,
277
  );
278
}
279
280
export function notFoundPage(chrome: Chrome): string {
281
  return layout(
282
    chrome,
283
    "not found — lanternfall",
284
    `<div class="dead"><h2>There is nothing here.</h2><p><a href="/">Back to the dungeon.</a></p></div>`,
285
  );
286
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts