benjivers/val Public Public: anyone can read this; the crew can change it.

Engine renderer: colour non-mate bodies by an optional `side` (#42)

`mate` alone gives a renderer two colours, so a game of three sides or a
free-for-all had nowhere to go, and every game copied from this engine
would inherit that limit. Bodies may now carry `side: string`. Each non-mate
side gets a colour from a small fixed palette (static/engine/sides.js),
handed out in the order sides are first seen and kept for the page's
life, so a side's colour holds steady between frames and when other sides
leave sight. Mates, you and the dead keep their colours. A body without
`side` draws exactly as before, and tests now pin today's colours.

The body contract takes `side` as optional and checks its type. Presence
carries it through to last-seen marks so their outlines match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Wren committed e7ab8b5 Browse files
11 files changed +271 −10
src/engine/body.test.ts +5
@@ -12,3 +12,8 @@ test("a well-formed body has no problems, and each missing or mistyped field is
1212 expect(bodyProblems(null)).toEqual(["body is not an object"]);
1313 expect(meProblems({ id: "a" })).toContain("me.ammo should be a number, is undefined");
1414 });
15
16 test("`side` may be left out, but when it is sent it is a string", () => {
17 expect(bodyProblems({ ...good, side: "north" })).toEqual([]);
18 expect(bodyProblems({ ...good, side: 3 })).toEqual(["body.side should be a string, is 3"]);
19 });
src/engine/body.ts +16 −3
@@ -16,6 +16,12 @@
1616 * hp: number 0..100; a bar shows under 100
1717 * mate: boolean on the viewer's side
1818 *
19 * and may have:
20 * side: string which side it is on, for a game of three sides or
21 * more; each non-mate side then draws in its own colour
22 * (`static/engine/sides.js`). Leave it out and every
23 * non-mate draws in the one enemy colour, as before.
24 *
1925 * `snap.me` has, besides id, x, y, angle, alive and hp:
2026 * ammo: number rounds in the magazine
2127 * reloading: boolean mid-reload
@@ -34,6 +40,11 @@ const BODY: Record<string, Kind> = {
3440 mate: "boolean",
3541 };
3642
43 /** Fields a body may leave out, but must type right when it sends them. */
44 const BODY_OPTIONAL: Record<string, Kind> = {
45 side: "string",
46 };
47
3748 const ME: Record<string, Kind> = {
3849 id: "string",
3950 x: "number",
@@ -45,18 +56,20 @@ const ME: Record<string, Kind> = {
4556 reloading: "boolean",
4657 };
4758
48 function problems(value: unknown, shape: Record<string, Kind>, label: string): string[] {
59 function problems(value: unknown, shape: Record<string, Kind>, label: string, optional: Record<string, Kind> = {}): string[] {
4960 if (typeof value !== "object" || value === null) return [`${label} is not an object`];
5061 const out: string[] = [];
51 for (const [field, kind] of Object.entries(shape)) {
62 const fields = [...Object.entries(shape), ...Object.entries(optional)];
63 for (const [field, kind] of fields) {
5264 const got = (value as Record<string, unknown>)[field];
65 if (got === undefined && field in optional) continue;
5366 if (typeof got !== kind || (kind === "number" && !Number.isFinite(got))) out.push(`${label}.${field} should be a ${kind}, is ${String(got)}`);
5467 }
5568 return out;
5669 }
5770
5871 /** What is wrong with one body of `snap.players`; empty when it is fit to draw. */
59 export const bodyProblems = (body: unknown, label = "body"): string[] => problems(body, BODY, label);
72 export const bodyProblems = (body: unknown, label = "body"): string[] => problems(body, BODY, label, BODY_OPTIONAL);
6073
6174 /** What is wrong with `snap.me`; empty when the gun and the camera can read it. */
6275 export const meProblems = (me: unknown): string[] => problems(me, ME, "me");
src/engine/boundary.test.ts +1
@@ -59,6 +59,7 @@ test("the engine folders exist and hold the pieces a game stands on", () => {
5959 "engine/budget.js",
6060 "engine/topdown.js",
6161 "engine/gun.js",
62 "engine/sides.js",
6263 ])
6364 expect(names).toContain(need);
6465 });
src/engine/presence.test.ts +7
@@ -160,3 +160,10 @@ test("two bodies are tracked apart", () => {
160160 const marks = ghosts(p, 10.2);
161161 expect(marks.map((m) => m.id)).toEqual(["b"]);
162162 });
163
164 test("a last-seen mark remembers the side the body was on", () => {
165 const p = makePresence();
166 sighted(p, [body({ side: "third" })], 10);
167 sighted(p, [], 10.2);
168 expect(ghosts(p, 10.2)[0]!.side).toBe("third");
169 });
src/engine/scene.test.ts +64 −2
@@ -11,7 +11,7 @@ import { expect, test } from "bun:test";
1111 import { GHOST, ghosts, makePresence, shown, sighted } from "../../static/engine/presence.js";
1212 import { drawScene, makeScene } from "../../static/engine/scene.js";
1313
14 type Op = { op: string; args: unknown[]; alpha: number };
14 type Op = { op: string; args: unknown[]; alpha: number; fill: string; stroke: string };
1515
1616 /** A canvas that records. Every call the renderer makes has to exist here. */
1717 function fakeContext() {
@@ -21,7 +21,7 @@ function fakeContext() {
2121 (...args: unknown[]) => {
2222 // The opacity in force when the call was made: how faded a body
2323 // arriving into sight is, and how far a last-seen mark has dimmed.
24 ops.push({ op, args, alpha: ctx.globalAlpha });
24 ops.push({ op, args, alpha: ctx.globalAlpha, fill: ctx.fillStyle, stroke: ctx.strokeStyle });
2525 };
2626 const ctx = {
2727 ops,
@@ -234,3 +234,65 @@ test("a mark where the wall now stands between you and it is not drawn", () => {
234234 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
235235 expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false);
236236 });
237
238 // ------------------------------------------------------- colour, by side
239
240 /** The fill of each upright body, left to right: the rect its edge is drawn round. */
241 function skins(ctx: ReturnType<typeof fakeContext>): string[] {
242 const out: { x: number; fill: string }[] = [];
243 ctx.ops.forEach((o, i) => {
244 if (o.op !== "strokeRect") return;
245 const body = ctx.ops[i - 1]!;
246 if (body.op === "fillRect") out.push({ x: Number(body.args[0]), fill: body.fill });
247 });
248 return out.sort((a, b) => a.x - b.x).map((b) => b.fill);
249 }
250
251 test("without `side`, a mate and an enemy wear exactly today's colours", () => {
252 const ctx = fakeContext();
253 drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ id: "m", mate: true, y: 150 }), body({ id: "e", y: 250 })]), view);
254 // Looking east, the smaller y is on the left.
255 expect(skins(ctx)).toEqual(["#3f9fd6", "#d8483f"]);
256 // And a last-seen enemy is outlined in today's colour too.
257 const marked = fakeContext();
258 drawScene(marked as never, makeScene(), world, eye, snapshot([]), {
259 ...view,
260 marks: [{ id: "e", x: 280, y: 200, angle: 0, mate: false, fade: 1 }],
261 });
262 expect(marked.ops.find((o) => o.op === "strokeRect")!.stroke).toBe("rgba(235,150,120,0.9)");
263 });
264
265 test("three enemy sides draw in three colours, and each keeps its colour next frame", () => {
266 const three = [
267 body({ id: "a", side: "scene-north", y: 130 }),
268 body({ id: "b", side: "scene-east", y: 200 }),
269 body({ id: "c", side: "scene-west", y: 270 }),
270 ];
271 const first = fakeContext();
272 drawScene(first as never, makeScene(), world, eye, snapshot(three), view);
273 const colours = skins(first);
274 expect(colours).toHaveLength(3);
275 expect(new Set(colours).size).toBe(3);
276 // None of them is the mate's blue.
277 expect(colours).not.toContain("#3f9fd6");
278
279 // Next frame the server lists them the other way round: same colours.
280 const second = fakeContext();
281 drawScene(second as never, makeScene(), world, eye, snapshot([...three].reverse()), view);
282 expect(skins(second)).toEqual(colours);
283
284 // And a side alone on screen still wears its own, not the first slot's.
285 const alone = fakeContext();
286 drawScene(alone as never, makeScene(), world, eye, snapshot([three[2]]), view);
287 expect(skins(alone)).toEqual([colours[2]!]);
288 });
289
290 test("a mate with a side is still the mate's colour, and the dead are still grey", () => {
291 const ctx = fakeContext();
292 drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, side: "scene-mine" })]), view);
293 expect(skins(ctx)).toEqual(["#3f9fd6"]);
294
295 const dead = fakeContext();
296 drawScene(dead as never, makeScene(), world, eye, snapshot([body({ side: "scene-dead", alive: false, hp: 0 })]), view);
297 expect(dead.ops.find((o) => o.op === "fill")!.fill).toBe("#4a4e57");
298 });
src/engine/sides.test.ts added +34
@@ -0,0 +1,34 @@
1 /** Which colour a side is handed, and that it keeps it. */
2 import { expect, test } from "bun:test";
3 import { ENEMY, enemyColours, makeSides, PALETTE } from "../../static/engine/sides.js";
4
5 test("a body with no side wears today's enemy colours, and takes no slot", () => {
6 const book = makeSides();
7 expect(enemyColours({}, book)).toBe(ENEMY);
8 expect(ENEMY).toEqual({ skin: "#d8483f", dot: "#e35d5b", mark: "rgba(235,150,120,0.9)" });
9 expect(book.size).toBe(0);
10 });
11
12 test("sides are handed the palette in the order they are first seen, and keep it", () => {
13 const book = makeSides();
14 const a = enemyColours({ side: "a" }, book);
15 const b = enemyColours({ side: "b" }, book);
16 const c = enemyColours({ side: "c" }, book);
17 expect([a, b, c]).toEqual([PALETTE[0], PALETTE[1], PALETTE[2]]);
18 // The first side is the enemy red a two-side game already wears.
19 expect(a).toBe(ENEMY);
20 expect(enemyColours({ side: "c" }, book)).toBe(c);
21 expect(enemyColours({ side: "a" }, book)).toBe(a);
22 });
23
24 test("the palette is all different, and none of it is a mate, the dead or you", () => {
25 const all = PALETTE.flatMap((p) => [p.skin, p.dot]);
26 expect(new Set(all).size).toBe(all.length);
27 for (const taken of ["#3f9fd6", "#4fb3d9", "#eaeaea", "#555a63", "#4a4e57"]) expect(all).not.toContain(taken);
28 });
29
30 test("more sides than colours wrap round rather than fail", () => {
31 const book = makeSides();
32 for (let i = 0; i < PALETTE.length; i++) enemyColours({ side: `s${i}` }, book);
33 expect(enemyColours({ side: "one more" }, book)).toBe(PALETTE[0]);
34 });
src/engine/topdown.test.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The board from above, drawn into a canvas that writes down the colour of
3 * every dot. What is pinned here is colour: who is drawn in which.
4 */
5 import { expect, test } from "bun:test";
6 import { drawTopdown } from "../../static/engine/topdown.js";
7
8 function fakeContext() {
9 const dots: { x: number; y: number; fill: string }[] = [];
10 const rings: string[] = [];
11 let at: { x: number; y: number } | null = null;
12 const noop = () => {};
13 const ctx = {
14 dots,
15 rings,
16 fillStyle: "",
17 strokeStyle: "",
18 lineWidth: 1,
19 font: "",
20 textAlign: "left",
21 globalAlpha: 1,
22 save: noop,
23 restore: noop,
24 beginPath: () => {
25 at = null;
26 },
27 closePath: noop,
28 clip: noop,
29 rect: noop,
30 translate: noop,
31 rotate: noop,
32 moveTo: noop,
33 lineTo: noop,
34 fillRect: noop,
35 fillText: noop,
36 arc: (x: number, y: number) => {
37 at = { x, y };
38 },
39 fill: () => {
40 if (at) dots.push({ ...at, fill: ctx.fillStyle });
41 },
42 stroke: () => {
43 if (ctx.globalAlpha < 1) rings.push(ctx.strokeStyle);
44 },
45 };
46 return ctx;
47 }
48
49 const map = { rows: ["....", "....", "....", "...."], tile: 100 };
50 const box = { x: 0, y: 0, w: 400, h: 400 };
51 const me = { id: "me", name: "Me", x: 50, y: 50, angle: 0, alive: true, hp: 100, mate: true };
52 const body = (over: Record<string, unknown>) => ({ name: "B", y: 200, angle: 0, alive: true, hp: 100, mate: false, ...over });
53 const draw = (players: unknown[], marks?: unknown[]) => {
54 const ctx = fakeContext();
55 drawTopdown(ctx as never, map, { me, players: [me, ...players], shots: [] }, (p: unknown) => p, box, { marks, labels: false });
56 return ctx;
57 };
58 /** Body dots, left to right; the arrow that is you is not a dot. */
59 const dots = (ctx: ReturnType<typeof fakeContext>) => [...ctx.dots].sort((a, b) => a.x - b.x).map((d) => d.fill);
60
61 test("without `side`, a mate, an enemy and a fallen mate wear exactly today's colours", () => {
62 const ctx = draw([body({ id: "m", mate: true, x: 100 }), body({ id: "e", x: 200 }), body({ id: "d", mate: true, alive: false, x: 300 })]);
63 expect(dots(ctx)).toEqual(["#4fb3d9", "#e35d5b"]);
64 expect(draw([], [{ id: "e", x: 200, y: 200, angle: 0, mate: false, fade: 0.5 }]).rings).toEqual(["#e35d5b", "#e35d5b"]);
65 });
66
67 test("three enemy sides are three colours on the map, the same frame after frame", () => {
68 const three = [
69 body({ id: "a", side: "map-red", x: 100 }),
70 body({ id: "b", side: "map-gold", x: 200 }),
71 body({ id: "c", side: "map-violet", x: 300 }),
72 ];
73 const first = dots(draw(three));
74 expect(new Set(first).size).toBe(3);
75 expect(first).not.toContain("#4fb3d9");
76 expect(dots(draw([...three].reverse()))).toEqual(first);
77 // A last-seen ring wears its side's colour, not the default enemy's.
78 const ring = draw([], [{ id: "c", x: 300, y: 200, angle: 0, mate: false, side: "map-violet", fade: 0.5 }]).rings;
79 expect(ring[0]).toBe(first[2]);
80 });
static/engine/presence.js +2 −1
@@ -74,6 +74,7 @@ function remember(it, body, seq) {
7474 it.angle = body.angle;
7575 it.alive = body.alive;
7676 it.mate = body.mate;
77 it.side = body.side;
7778 }
7879
7980 /**
@@ -124,7 +125,7 @@ export function ghosts(presence, now) {
124125 presence.last.delete(id);
125126 continue;
126127 }
127 out.push({ id, x: it.x, y: it.y, angle: it.angle, mate: it.mate, fade: left });
128 out.push({ id, x: it.x, y: it.y, angle: it.angle, mate: it.mate, side: it.side, fade: left });
128129 }
129130 return out;
130131 }
static/engine/scene.js +3 −2
@@ -20,6 +20,7 @@
2020 */
2121
2222 import { swell } from "./presence.js";
23 import { enemyColours } from "./sides.js";
2324 import { makeColumns, SKY, wallRuns } from "./slices.js";
2425 import {
2526 castRay,
@@ -300,7 +301,7 @@ function drawBody(ctx, thing, floorY, clip, trim, badge) {
300301 return;
301302 }
302303
303 const skin = mate ? "#3f9fd6" : "#d8483f";
304 const skin = mate ? "#3f9fd6" : enemyColours(p).skin;
304305 const stripe = trim(p);
305306 const mark = badge(p);
306307 const edge = Math.max(1.2, h * 0.05);
@@ -350,7 +351,7 @@ function drawMark(ctx, thing, floorY, clip) {
350351 const cx = thing.column;
351352 const top = floorY - h;
352353 clip(thing, cx - w * 1.2, cx + w * 1.2, () => {
353 ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)";
354 ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : enemyColours(thing.mark).mark;
354355 ctx.lineWidth = Math.max(1, h * 0.035);
355356 ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
356357 ctx.fillStyle = "rgba(10,12,16,0.45)";
static/engine/sides.js added +55
@@ -0,0 +1,55 @@
1 /**
2 * What colour a body is drawn in.
3 *
4 * Every body says `mate`: on the viewer's side or not. That is enough for a
5 * game of two sides, and a game that sends nothing more looks exactly as it
6 * always has. A game of three sides, or everybody for themselves, also sends
7 * `side` — any string naming the body's side — and each side that is not the
8 * viewer's gets its own colour from a small fixed palette.
9 *
10 * A side keeps the colour it was first given for as long as the page is
11 * open, so it does not change hue between frames, or when another side walks
12 * out of sight. The first side seen gets the enemy red a two-side game
13 * already wears. More sides than the palette has wrap round to its start.
14 *
15 * Mates keep the mate colour, you keep yours and the dead keep their grey,
16 * whatever side they are on: those are drawn by the callers, not here.
17 */
18
19 /** Today's enemy, and what any body without a `side` wears. */
20 export const ENEMY = { skin: "#d8483f", dot: "#e35d5b", mark: "rgba(235,150,120,0.9)" };
21
22 /**
23 * The palette, in the order sides are handed it. Each is `skin` in the first
24 * person, `dot` on the map and `mark` for a last-seen outline. None is blue
25 * (mates), grey (the dead) or white (you).
26 */
27 export const PALETTE = [
28 ENEMY,
29 { skin: "#e08a2c", dot: "#f0a040", mark: "rgba(240,175,100,0.9)" },
30 { skin: "#9b5fd6", dot: "#b07ae8", mark: "rgba(190,150,235,0.9)" },
31 { skin: "#4fae4a", dot: "#62c45c", mark: "rgba(140,210,130,0.9)" },
32 { skin: "#d655a8", dot: "#e86fbb", mark: "rgba(235,150,205,0.9)" },
33 { skin: "#c9b52e", dot: "#ddc944", mark: "rgba(225,210,120,0.9)" },
34 ];
35
36 /** A fresh book of which side has which colour. */
37 export const makeSides = () => new Map();
38
39 /** The one book the renderers share, so the map and the scene agree. */
40 const shared = makeSides();
41
42 /**
43 * The colours for a body that is not on the viewer's side: by its `side`
44 * when it carries one, today's enemy colours when it does not.
45 */
46 export function enemyColours(body, book = shared) {
47 const side = body.side;
48 if (typeof side !== "string") return ENEMY;
49 let at = book.get(side);
50 if (at === undefined) {
51 at = book.size % PALETTE.length;
52 book.set(side, at);
53 }
54 return PALETTE[at];
55 }
static/engine/topdown.js +4 −2
@@ -11,6 +11,8 @@
1111 * for a badge, the game says through `opts` — see `drawTopdown`.
1212 */
1313
14 import { enemyColours } from "./sides.js";
15
1416 const WALL_COLOUR = "#20242d";
1517 const FLOOR_COLOUR = "#171a21";
1618
@@ -101,7 +103,7 @@ function drawMark(ctx, m, s) {
101103 const r = Math.max(2.5, 16 * s);
102104 ctx.save();
103105 ctx.globalAlpha = 0.8 * m.fade;
104 ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b";
106 ctx.strokeStyle = m.mate ? "#4fb3d9" : enemyColours(m).dot;
105107 ctx.lineWidth = Math.max(1, 2 * s);
106108 disc(ctx, x, y, r, false, true);
107109 ctx.beginPath();
@@ -130,7 +132,7 @@ function drawPlayer(ctx, p, pos, s, me, labels, trim, badges) {
130132 const x = pos.x * s;
131133 const y = pos.y * s;
132134 const r = Math.max(2.5, 16 * s);
133 const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
135 const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : enemyColours(p).dot;
134136
135137 if (mine && p.alive) {
136138 ctx.save();
↑↓ navigate ↵ open esc close

Keyboard shortcuts