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

forked from benjivers/val

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

Keyboard shortcuts