fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files d7211248ad6d576d3c93536129a0d746e3c90526
1
/**
2
 * The page. The first paint is the match as it stands right now — score,
3
 * round, phase, scoreboard — server-rendered HTML, so somebody arriving with
4
 * a cold cache or no JavaScript still sees a live match. The client script
5
 * then takes the same state over a socket and draws it.
6
 */
7
8
import { AGENTS } from "../abilities.ts";
9
import type { Result } from "../db.ts";
10
import type { MatchState } from "../state.ts";
11
import { WEAPONS, weaponIds } from "../weapons.ts";
12
13
export const escape = (s: string): string =>
14
  s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
15
16
const phaseWord = (state: MatchState): string =>
17
  state.phase === "buy"
18
    ? "buy phase"
19
    : state.phase === "live"
20
      ? state.spike.planted
21
        ? `spike down · ${Math.ceil(state.spike.fuse)}s`
22
        : "live"
23
      : state.phase === "end"
24
        ? state.last
25
          ? `round over — ${state.last.winner} took it, ${state.last.why.charAt(0).toLowerCase()}${state.last.why.slice(1)}`
26
          : "round over"
27
        : "match over";
28
29
export function gamePage(opts: {
30
  state: MatchState;
31
  script: string;
32
  name: string;
33
  env: string;
34
  commit: string;
35
  ssh: string | undefined;
36
  results: Result[];
37
}): string {
38
  const { state } = opts;
39
  const board = [...state.players].sort((a, b) => b.kills - a.kills);
40
  const row = (side: "atk" | "def"): string =>
41
    board
42
      .filter((p) => p.side === side)
43
      .map(
44
        (p) =>
45
          `<tr><td>${escape(p.name)}${p.bot ? " <small>bot</small>" : ""}</td><td>${escape(p.agent)}</td><td>${p.kills}</td><td>${p.deaths}</td></tr>`,
46
      )
47
      .join("");
48
49
  return `<!doctype html>
50
<html lang="en">
51
<meta charset="utf-8">
52
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
53
<title>val — attack, defend, plant the spike</title>
54
<link rel="icon" href="data:,">
55
<style>
56
  :root { color-scheme: dark; --atk: #e35d5b; --def: #4fb3d9; --ink: #e8e6e3; }
57
  * { box-sizing: border-box; }
58
  body { margin: 0; background: #0e0f12; color: var(--ink);
59
         font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
60
  .wrap { max-width: 1100px; margin: 0 auto; padding: .6rem; }
61
  h1 { font-size: 1.1rem; margin: 0; letter-spacing: .12em; text-transform: uppercase; }
62
  header.top { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
63
  .env { font-size: .7rem; padding: .1rem .5rem; border-radius: 99px; background: #2a2d34; }
64
  #scoreline { display: flex; align-items: center; justify-content: center; gap: .6rem;
65
               font-variant-numeric: tabular-nums; margin: .4rem 0; font-size: 1.05rem; }
66
  .red { color: var(--atk); font-weight: 700; } .blue { color: var(--def); font-weight: 700; }
67
  #clock { font-size: 1.5rem; font-weight: 700; min-width: 3ch; text-align: center; }
68
  #phase { opacity: .75; font-size: .85rem; }
69
  #stage { position: relative; width: 100%; aspect-ratio: 30 / 19; background: #14161b;
70
           border: 1px solid #262a33; border-radius: .6rem; overflow: hidden; touch-action: none; }
71
  #view { width: 100%; height: 100%; display: block; }
72
  #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; }
73
  #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008;
74
          font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; }
75
  #prompt { position: absolute; left: 50%; transform: translateX(-50%); bottom: .6rem;
76
            max-width: 92%; padding: .35rem .7rem; border-radius: .4rem; background: #0b0d11cc;
77
            border: 1px solid #2f3541; font-size: .9rem; text-align: center; pointer-events: none; }
78
  #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center;
79
             font-size: .85rem; letter-spacing: .08em; text-transform: uppercase;
80
             text-shadow: 0 1px 4px #000; pointer-events: none; }
81
  #perf { position: absolute; left: .5rem; bottom: 2.6rem; display: none; pointer-events: none;
82
          padding: .2rem .45rem; border-radius: .3rem; background: #0b0d11cc; border: 1px solid #2f3541;
83
          font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap;
84
          max-width: calc(100% - 1rem); overflow: hidden; text-overflow: ellipsis; }
85
  #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center;
86
          background: #080a0ef2; padding: .8rem; overflow: auto; }
87
  #help > div { max-width: 40rem; }
88
  #help h2 { font-size: 1rem; margin: 0 0 .4rem; letter-spacing: .1em; text-transform: uppercase; }
89
  #help p, #help ul { margin: .4rem 0; font-size: .88rem; }
90
  #help ul { padding-left: 1.1rem; }
91
  #help kbd { background: #222732; border: 1px solid #39404e; border-radius: .25rem;
92
              padding: 0 .3rem; font: inherit; font-size: .82rem; }
93
  #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356;
94
               color: var(--ink); border: 0; border-radius: .45rem; font: inherit; cursor: pointer; }
95
  #roster { display: grid; grid-template-columns: repeat(5, 1fr); gap: .35rem; margin: .5rem 0;
96
            min-height: 3.1rem; }
97
  .slot { background: #171a21; border: 1px solid #262b35; border-left: 3px solid #4a5162;
98
          border-radius: .35rem; padding: .2rem .35rem; font-size: .72rem; overflow: hidden; }
99
  .slot .who { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
100
  .slot .agent { display: block; opacity: .6; font-size: .66rem; white-space: nowrap;
101
                 overflow: hidden; text-overflow: ellipsis; }
102
  .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; }
103
  .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; }
104
  .slot.mine { border-color: #6f8ecf; background: #1b2130; }
105
  .slot.down { opacity: .42; filter: grayscale(1); }
106
  .slot.down .who { text-decoration: line-through; }
107
  .slot .spike { color: #f0d060; }
108
  #feed { position: absolute; top: .5rem; right: .5rem; width: min(17rem, 52%); display: grid;
109
          gap: .18rem; justify-items: end; pointer-events: none; font-size: .76rem; }
110
  #feed div { background: #0b0d11cc; border: 1px solid #2f3541; border-radius: .3rem;
111
              padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
112
              max-width: 100%; }
113
  #feed .k { color: #8ef0c4; } #feed .v { color: #e39a95; } #feed .w { opacity: .65; }
114
  #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0;
115
         font-variant-numeric: tabular-nums; }
116
  .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem;
117
          font-size: .85rem; }
118
  .kit { display: flex; gap: .4rem; flex-wrap: wrap; }
119
  .kit button, .buys button, .agents button { min-height: 44px; min-width: 44px; padding: .4rem .6rem;
120
          background: #1b1e25; color: var(--ink); border: 1px solid #333a47; border-radius: .45rem;
121
          font: inherit; font-size: .8rem; cursor: pointer; }
122
  .kit button[disabled], .buys button[disabled] { opacity: .35; cursor: not-allowed; }
123
  .buys button.owned { outline: 2px solid #6fcf9f; opacity: 1; }
124
  .buys button.owned[disabled] { cursor: default; }
125
  .kit b { display: block; font-size: .7rem; opacity: .7; }
126
  #buy { display: none; margin: .5rem 0; padding: .6rem; background: #151820; border: 1px solid #2a2f3a;
127
         border-radius: .6rem; }
128
  #buy.on { display: block; }
129
  #buy p { margin: 0 0 .3rem; font-size: .85rem; opacity: .85; }
130
  .buys, .agents { display: flex; gap: .4rem; flex-wrap: wrap; margin: .35rem 0; align-items: center; }
131
  .lbl { min-width: 4.2rem; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; opacity: .55; }
132
  #log { margin: .5rem 0; font-size: .82rem; opacity: .8; min-height: 3.2em; }
133
  #log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
134
  table { width: 100%; border-collapse: collapse; font-size: .85rem; }
135
  th, td { text-align: left; padding: .25rem .4rem; border-bottom: 1px solid #23262e; }
136
  td small { opacity: .5; }
137
  .boards { display: grid; gap: .8rem; grid-template-columns: 1fr; }
138
  @media (min-width: 720px) { .boards { grid-template-columns: 1fr 1fr; } }
139
  footer { margin: 1.2rem 0 3rem; font-size: .82rem; opacity: .72; }
140
  code { background: #1b1e25; padding: .1rem .35rem; border-radius: .3rem; }
141
  form.name { display: flex; gap: .4rem; margin: .5rem 0; }
142
  form.name input { flex: 1; min-height: 44px; padding: .4rem .6rem; background: #1b1e25; color: var(--ink);
143
          border: 1px solid #333a47; border-radius: .45rem; font: inherit; }
144
  form.name button { min-height: 44px; padding: 0 1rem; background: #3a4356; color: var(--ink);
145
          border: 0; border-radius: .45rem; font: inherit; cursor: pointer; }
146
  #touch { display: none; }
147
  @media (pointer: coarse) { #touch { display: block; } }
148
</style>
149
<div class="wrap">
150
<header class="top">
151
  <h1>val</h1>
152
  <span class="env">${escape(opts.env)}${opts.commit ? " @ " + escape(opts.commit) : ""}</span>
153
  <span class="env" id="conn">connecting…</span>
154
</header>
155
156
<div id="scoreline">
157
  <span class="red">red ${state.score.red}</span>
158
  <span id="clock">${Math.max(0, Math.ceil(state.clock))}</span>
159
  <span class="blue">${state.score.blue} blue</span>
160
</div>
161
<div id="phase">round ${state.round} · ${escape(phaseWord(state))} · ${escape(state.attackers)} attacking${state.result ? ` · ${escape(state.result)}` : ""}</div>
162
163
<div id="stage">
164
  <canvas id="view" width="900" height="570"></canvas>
165
  <div id="flash"></div>
166
  <div id="aimhint">click to aim · esc releases</div>
167
  <div id="dead">waiting for the next round</div>
168
  <div id="feed"></div>
169
  <div id="perf" aria-live="off"></div>
170
  <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
171
  <div id="help" role="dialog" aria-label="How to play val"><div>
172
    <h2>How to play</h2>
173
    <p><strong>The round.</strong> Attackers carry the spike to site A or B and plant it;
174
       defenders stop them, or defuse it once it is down. Plant and it runs a 45s fuse.
175
       First team to 13 rounds wins, and the sides swap at 12.</p>
176
    <p><strong>The teams.</strong> You are <span class="red">red</span> or
177
       <span class="blue">blue</span>; each round one team attacks and the other defends.
178
       Team mates carry a name tag over their head, enemies never do.</p>
179
    <p><strong>Controls.</strong> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move ·
180
       click the view to capture the mouse and look, <kbd>Esc</kbd> releases ·
181
       click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up ·
182
       <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view ·
183
       <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back ·
184
       <kbd>F3</kbd> or <kbd>\`</kbd> shows the frame readout.</p>
185
    <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
186
       last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
187
       the ultimate, paid for in ult points you earn by playing.</p>
188
    <p><strong>Telling people apart.</strong> Your five are always listed under the view with their
189
       health — struck through when they are down — and the last few kills scroll in the top right.
190
       In the world an enemy wears a red chevron over their head and a hard dark outline, a team mate
191
       wears their name and a health pip, and anybody lying flat on the floor is dead. On the map your
192
       team is always shown (crossed out when down) and enemies appear only while you can see them.</p>
193
    <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right
194
       thumb aims and fires, and the buttons under the map buy and cast.</p>
195
    <ul id="helpagents"></ul>
196
    <button id="helpclose" type="button">Got it — play</button>
197
  </div></div>
198
</div>
199
200
<div id="roster" aria-label="your team">${["", "", "", "", ""]
201
  .map(() => `<div class="slot"><span class="who">—</span><span class="hp"><i style="width:0"></i></span></div>`)
202
  .join("")}</div>
203
204
<div id="hud">
205
  <span class="chip" id="hp">100 hp</span>
206
  <span class="chip" id="gun">Classic 12</span>
207
  <span class="chip" id="creds">800 cr</span>
208
  <span class="chip" id="ultmeter">ult 0/6</span>
209
  <span class="kit" id="kit"></span>
210
  <button id="buytoggle" class="chip" type="button">buy (B)</button>
211
  <button id="viewtoggle" class="chip" type="button">view (V)</button>
212
  <button id="helptoggle" class="chip" type="button">how to play (H)</button>
213
</div>
214
215
<div id="buy">
216
  <p><strong>Buy phase.</strong> Spend your credits on a gun and armour for this round;
217
     a greyed price is more than you have, and the outlined one is what you hold.
218
     Two abilities are bought here too, and your agent can be swapped until you buy one.</p>
219
  <div class="agents"><span class="lbl">agent</span><span class="agents" id="agents">${AGENTS.map(
220
    (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`,
221
  ).join("")}</span></div>
222
  <div class="buys"><span class="lbl">gun</span><span class="buys" id="guns">${weaponIds()
223
    .map(
224
      (id) =>
225
        `<button type="button" data-buy="${id}" data-cost="${WEAPONS[id].cost}" title="${escape(WEAPONS[id].name)} — ${WEAPONS[id].cost} credits, ${WEAPONS[id].magazine} rounds, ${WEAPONS[id].auto ? "automatic" : "semi-automatic"}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`,
226
    )
227
    .join("")}</span></div>
228
  <div class="buys"><span class="lbl">armour</span><span class="buys" id="gear">
229
    <button type="button" data-buy="light" data-cost="400" title="25 armour that soaks a quarter of what hits you">Light shield <b>400</b></button>
230
    <button type="button" data-buy="heavy" data-cost="1000" title="50 armour that soaks half of what hits you">Heavy shield <b>1000</b></button>
231
  </span></div>
232
  <div class="buys"><span class="lbl">abilities</span><span class="buys" id="basics">
233
    <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button>
234
    <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button>
235
  </span></div>
236
</div>
237
238
<div id="log">${state.events
239
    .slice(0, 3)
240
    .map((e) => `<div>${escape(e.text)}</div>`)
241
    .join("")}</div>
242
243
<form class="name" method="post" action="/join">
244
  <input name="name" maxlength="16" placeholder="Your name" value="${escape(opts.name)}" aria-label="Your name">
245
  <button>Take a seat</button>
246
</form>
247
248
<div class="boards">
249
  <div><h2 class="env">attackers</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="atkboard">${row("atk")}</tbody></table></div>
250
  <div><h2 class="env">defenders</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="defboard">${row("def")}</tbody></table></div>
251
</div>
252
253
<footer>
254
  <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look,
255
     Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) ·
256
     C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view ·
257
     B opens the buy · H shows how to play · F3 or \` prints the frame numbers:
258
     frame rate, mean frame, snapshots a second, the interpolation delay and how
259
     wide the world is being drawn before it is scaled onto the canvas.
260
     On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p>
261
  <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
262
     7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
263
     nobody is in, and they use their kit.</p>
264
  <p>Terminal: ${opts.ssh ? `<code>${escape(opts.ssh)}</code>` : "off in this environment."}</p>
265
  ${opts.results.length ? `<p>Last matches: ${opts.results.map((r) => `${escape(r.summary)} <small>(${escape(r.ended_at)})</small>`).join(" · ")}</p>` : ""}
266
</footer>
267
</div>
268
<script src="${escape(opts.script)}" defer></script>
269
</html>
270
`;
271
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts