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

forked from benjivers/val

workbenchthis machine

Put the team on screen: five slots, health, and the dead struck through

Who is with you and who is still up were only answerable by looking at a
body you could currently see, which for most of a round is nobody. Every
team mate is already in every snapshot — the server shows you your own side
— so the roster is a read of what was always there: five slots in a stable
order, the agent's colour down the edge, a health bar, and struck through
and desaturated when somebody is dead. No server change.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 175dd79 Browse files
5 files changed +165 −2
src/client.test.ts +6 −1
@@ -102,7 +102,7 @@ const nodes = new Map<string, Record<string, unknown>>();
102102 for (const id of [
103103 "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
104104 "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
105 "log", "atkboard", "defboard", "helpagents",
105 "log", "atkboard", "defboard", "helpagents", "roster", "feed",
106106 ])
107107 nodes.set(id, element(id));
108108
@@ -186,6 +186,11 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
186186 // And the buy panel prices itself off the agent's real kit.
187187 expect(String(buys[1]!.title)).toContain("charge");
188188
189 // The team is on screen whether anybody is in sight or not: five slots.
190 const slots = nodes.get("roster")!.children as { className: string }[];
191 expect(slots.length).toBe(5);
192 expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
193
189194 // A frame of the first-person view actually draws walls: a slice a column,
190195 // far more rectangles than the map has tiles.
191196 drawn.length = 0;
src/pages/game.ts +23
@@ -86,6 +86,24 @@ export function gamePage(opts: {
8686 padding: 0 .3rem; font: inherit; font-size: .82rem; }
8787 #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356;
8888 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 { opacity: .6; font-size: .66rem; }
95 .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; }
96 .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; }
97 .slot.mine { border-color: #6f8ecf; background: #1b2130; }
98 .slot.down { opacity: .42; filter: grayscale(1); }
99 .slot.down .who { text-decoration: line-through; }
100 .slot .spike { color: #f0d060; }
101 #feed { position: absolute; top: .5rem; right: .5rem; width: min(17rem, 52%); display: grid;
102 gap: .18rem; justify-items: end; pointer-events: none; font-size: .76rem; }
103 #feed div { background: #0b0d11cc; border: 1px solid #2f3541; border-radius: .3rem;
104 padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
105 max-width: 100%; }
106 #feed .k { color: #8ef0c4; } #feed .v { color: #e39a95; } #feed .w { opacity: .65; }
89107 #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0;
90108 font-variant-numeric: tabular-nums; }
91109 .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem;
@@ -136,6 +154,7 @@ export function gamePage(opts: {
136154 <div id="flash"></div>
137155 <div id="aimhint">click to aim · esc releases</div>
138156 <div id="dead">waiting for the next round</div>
157 <div id="feed"></div>
139158 <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
140159 <div id="help" role="dialog" aria-label="How to play val"><div>
141160 <h2>How to play</h2>
@@ -160,6 +179,10 @@ export function gamePage(opts: {
160179 </div></div>
161180 </div>
162181
182 <div id="roster" aria-label="your team">${["", "", "", "", ""]
183 .map(() => `<div class="slot"><span class="who">—</span><span class="hp"><i style="width:0"></i></span></div>`)
184 .join("")}</div>
185
163186 <div id="hud">
164187 <span class="chip" id="hp">100 hp</span>
165188 <span class="chip" id="gun">Classic 12</span>
src/roster.test.ts added +64
@@ -0,0 +1,64 @@
1 import { describe, expect, test } from "bun:test";
2 import { roster, rosterKey } from "../static/roster.js";
3
4 const snapshot = (players: unknown[]) => ({
5 me: { id: "p3" },
6 players,
7 });
8
9 const mate = (id: string, over: Record<string, unknown> = {}) => ({
10 id,
11 name: id.toUpperCase(),
12 agent: "veil",
13 hp: 100,
14 alive: true,
15 mate: true,
16 spike: false,
17 ...over,
18 });
19
20 describe("the team roster", () => {
21 test("is your own side, everybody, seen or not", () => {
22 const rows = roster(
23 snapshot([mate("p1"), mate("p2"), mate("p3"), mate("p4"), mate("p5"), mate("e1", { mate: false })]),
24 );
25 expect(rows.length).toBe(5);
26 expect(rows.some((r) => r.id === "e1")).toBe(false);
27 });
28
29 test("keeps one order whatever order the snapshot arrives in", () => {
30 const one = roster(snapshot([mate("p3"), mate("p1"), mate("p2")]));
31 const two = roster(snapshot([mate("p2"), mate("p3"), mate("p1")]));
32 expect(one.map((r) => r.id)).toEqual(two.map((r) => r.id));
33 expect(one.map((r) => r.id)).toEqual(["p1", "p2", "p3"]);
34 });
35
36 test("marks the dead and gives them no health", () => {
37 const rows = roster(snapshot([mate("p1", { alive: false, hp: 37 })]));
38 expect(rows[0]!.alive).toBe(false);
39 expect(rows[0]!.hp).toBe(0);
40 });
41
42 test("clamps a health the server rounded past the ends", () => {
43 expect(roster(snapshot([mate("p1", { hp: 140 })]))[0]!.hp).toBe(100);
44 expect(roster(snapshot([mate("p1", { hp: -5 })]))[0]!.hp).toBe(0);
45 });
46
47 test("knows which row is you, and who has the spike", () => {
48 const rows = roster(snapshot([mate("p1"), mate("p3", { spike: true })]));
49 expect(rows.find((r) => r.id === "p3")!.mine).toBe(true);
50 expect(rows.find((r) => r.id === "p3")!.spike).toBe(true);
51 expect(rows.find((r) => r.id === "p1")!.mine).toBe(false);
52 });
53
54 test("no snapshot is no rows, not a crash", () => {
55 expect(roster(null)).toEqual([]);
56 });
57
58 test("the key changes when anything on the panel changes, and not otherwise", () => {
59 const before = roster(snapshot([mate("p1", { hp: 80 })]));
60 expect(rosterKey(before)).toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80 })]))));
61 expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 40 })]))));
62 expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80, alive: false })]))));
63 });
64 });
static/roster.js added +30
@@ -0,0 +1,30 @@
1 /**
2 * Your five, always.
3 *
4 * Who is on your team and who is still standing must not depend on whether
5 * you can see them — for most of a round you cannot see anybody. Every team
6 * mate is in every snapshot already (the server shows you your own side), so
7 * this is a read of what is there: a fixed order, health clamped, and the
8 * dead marked.
9 */
10
11 /** The player's own team, in a stable order, ready to draw as rows. */
12 export function roster(snap) {
13 if (!snap || !snap.players) return [];
14 return snap.players
15 .filter((p) => p.mate)
16 .map((p) => ({
17 id: p.id,
18 name: p.name,
19 agent: p.agent,
20 hp: p.alive ? Math.max(0, Math.min(100, p.hp)) : 0,
21 alive: p.alive === true,
22 spike: p.spike === true,
23 mine: p.id === snap.me.id,
24 }))
25 .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
26 }
27
28 /** Enough of the roster to know when the panel has to be rebuilt. */
29 export const rosterKey = (rows) =>
30 rows.map((r) => `${r.id}:${r.agent}:${Math.round(r.hp)}:${r.alive ? 1 : 0}:${r.spike ? 1 : 0}`).join("|");
static/val.js +42 −1
@@ -14,7 +14,8 @@
1414 import { makeBeeper } from "./beep.js";
1515 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1616 import { choosePrompt, REACH } from "./prompt.js";
17 import { drawScene, makeScene } from "./scene.js";
17 import { roster, rosterKey } from "./roster.js";
18 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
1819 import { moveVector } from "./steer.js";
1920 import { drawTopdown } from "./topdown.js";
2021
@@ -412,6 +413,7 @@ function paintHud(s) {
412413
413414 paintKit(s);
414415 paintBuy(s);
416 paintRoster(s);
415417
416418 const log = $("log");
417419 const lines = s.events.slice(0, 3).join("\n");
@@ -459,6 +461,45 @@ function onSite(me) {
459461 return cell === "A" || cell === "B";
460462 }
461463
464 /**
465 * Your team, always on screen: five slots, health, and struck through when
466 * somebody is dead. The one place both questions — who is with me, who is
467 * still up — are answered whether anybody is in sight or not.
468 */
469 function paintRoster(s) {
470 const rows = roster(s);
471 const panel = $("roster");
472 const key = rosterKey(rows);
473 if (panel.dataset.key === key) return;
474 panel.dataset.key = key;
475 panel.replaceChildren();
476 for (const row of rows) {
477 const slot = document.createElement("div");
478 slot.className = `slot${row.mine ? " mine" : ""}${row.alive ? "" : " down"}`;
479 slot.style.borderLeftColor = AGENT_COLOUR[row.agent] || "#4a5162";
480 const who = document.createElement("span");
481 who.className = "who";
482 who.textContent = row.name;
483 const agent = document.createElement("span");
484 agent.className = "agent";
485 agent.textContent = row.alive ? row.agent : `${row.agent} · dead`;
486 if (row.spike) {
487 const spike = document.createElement("span");
488 spike.className = "spike";
489 spike.textContent = " spike";
490 agent.append(spike);
491 }
492 const bar = document.createElement("span");
493 bar.className = "hp";
494 const fill = document.createElement("i");
495 fill.style.width = `${row.hp}%`;
496 fill.style.background = row.hp > 40 ? "#6fcf6f" : "#e0a040";
497 bar.append(fill);
498 slot.append(who, agent, bar);
499 panel.append(slot);
500 }
501 }
502
462503 const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null);
463504
464505 /** The four ability buttons, each saying what it does and what it has left. */
↑↓ navigate ↵ open esc close

Keyboard shortcuts