pike/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]>
5 files
changed +165
−2
src/client.test.ts +6 −1
| @@ -102,7 +102,7 @@ const nodes = new Map<string, Record<string, unknown>>(); | ||
| 102 | 102 | for (const id of [ |
| 103 | 103 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", |
| 104 | 104 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", |
| 105 | "log", "atkboard", "defboard", "helpagents", | |
| 105 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", | |
| 106 | 106 | ]) |
| 107 | 107 | nodes.set(id, element(id)); |
| 108 | 108 | |
| @@ -186,6 +186,11 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 186 | 186 | // And the buy panel prices itself off the agent's real kit. |
| 187 | 187 | expect(String(buys[1]!.title)).toContain("charge"); |
| 188 | 188 | |
| 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 | ||
| 189 | 194 | // A frame of the first-person view actually draws walls: a slice a column, |
| 190 | 195 | // far more rectangles than the map has tiles. |
| 191 | 196 | drawn.length = 0; |
src/pages/game.ts +23
| @@ -86,6 +86,24 @@ export function gamePage(opts: { | ||
| 86 | 86 | padding: 0 .3rem; font: inherit; font-size: .82rem; } |
| 87 | 87 | #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356; |
| 88 | 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 { 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; } | |
| 89 | 107 | #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; |
| 90 | 108 | font-variant-numeric: tabular-nums; } |
| 91 | 109 | .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem; |
| @@ -136,6 +154,7 @@ export function gamePage(opts: { | ||
| 136 | 154 | <div id="flash"></div> |
| 137 | 155 | <div id="aimhint">click to aim · esc releases</div> |
| 138 | 156 | <div id="dead">waiting for the next round</div> |
| 157 | <div id="feed"></div> | |
| 139 | 158 | <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div> |
| 140 | 159 | <div id="help" role="dialog" aria-label="How to play val"><div> |
| 141 | 160 | <h2>How to play</h2> |
| @@ -160,6 +179,10 @@ export function gamePage(opts: { | ||
| 160 | 179 | </div></div> |
| 161 | 180 | </div> |
| 162 | 181 | |
| 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 | ||
| 163 | 186 | <div id="hud"> |
| 164 | 187 | <span class="chip" id="hp">100 hp</span> |
| 165 | 188 | <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 @@ | ||
| 14 | 14 | import { makeBeeper } from "./beep.js"; |
| 15 | 15 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 16 | 16 | 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"; | |
| 18 | 19 | import { moveVector } from "./steer.js"; |
| 19 | 20 | import { drawTopdown } from "./topdown.js"; |
| 20 | 21 | |
| @@ -412,6 +413,7 @@ function paintHud(s) { | ||
| 412 | 413 | |
| 413 | 414 | paintKit(s); |
| 414 | 415 | paintBuy(s); |
| 416 | paintRoster(s); | |
| 415 | 417 | |
| 416 | 418 | const log = $("log"); |
| 417 | 419 | const lines = s.events.slice(0, 3).join("\n"); |
| @@ -459,6 +461,45 @@ function onSite(me) { | ||
| 459 | 461 | return cell === "A" || cell === "B"; |
| 460 | 462 | } |
| 461 | 463 | |
| 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 | ||
| 462 | 503 | const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null); |
| 463 | 504 | |
| 464 | 505 | /** The four ability buttons, each saying what it does and what it has left. */ |