/** * The renderer, run for real against a canvas that writes down what it was * asked to draw. No browser, but every line of the draw path is executed — * which is what catches a typo in a frame nobody can see from a unit test. * * This is the engine's half: walls, bodies, marks, arrivals. What a game puts * on the floor besides is drawn through hooks and tested with the game. */ import { expect, test } from "bun:test"; import { GHOST, ghosts, makePresence, shown, sighted } from "../../static/engine/presence.js"; import { drawScene, makeScene } from "../../static/engine/scene.js"; type Op = { op: string; args: unknown[]; alpha: number; fill: string; stroke: string }; /** A canvas that records. Every call the renderer makes has to exist here. */ function fakeContext() { const ops: Op[] = []; const note = (op: string) => (...args: unknown[]) => { // The opacity in force when the call was made: how faded a body // arriving into sight is, and how far a last-seen mark has dimmed. ops.push({ op, args, alpha: ctx.globalAlpha, fill: ctx.fillStyle, stroke: ctx.strokeStyle }); }; const ctx = { ops, fillStyle: "", strokeStyle: "", lineWidth: 1, font: "", textAlign: "left", globalAlpha: 1, clearRect: note("clearRect"), fillRect: note("fillRect"), strokeRect: note("strokeRect"), beginPath: note("beginPath"), closePath: note("closePath"), moveTo: note("moveTo"), lineTo: note("lineTo"), arc: note("arc"), ellipse: note("ellipse"), fill: note("fill"), stroke: note("stroke"), clip: note("clip"), rect: note("rect"), save: note("save"), restore: note("restore"), translate: note("translate"), rotate: note("rotate"), fillText: note("fillText"), measureText: (text: string) => ({ width: text.length * 6 }), createLinearGradient: () => ({ addColorStop() {} }), }; return ctx; } // A room three tiles deep running east, with a pillar standing in it at // column 5 — enough geometry for a body to hide behind. const room = (col: number, row: number): boolean => col < 1 || col > 12 || row < 1 || row > 3 || col === 5; const TILE = 80; const world = { solid: room, tile: TILE }; // What the engine's renderer needs of a snapshot: you, who you can see, // and the shots you were let see. Nothing of the game. const snapshot = (players: unknown[]) => ({ me: { id: "me", x: 120, y: 200, angle: 0, alive: true, hp: 100 }, players, shots: [], }); const body = (over: Record = {}) => ({ id: "them", name: "Sable", x: 280, y: 200, angle: 3.1, hp: 100, alive: true, mate: false, ...over, }); const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; const eye = { x: 120, y: 200, angle: 0 }; test("a frame draws walls in runs: the whole width covered, far fewer fills", () => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); const rects = ctx.ops.filter((o) => o.op === "fillRect"); // The bands are the full width; the rest are wall runs. const walls = rects .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) })) .filter((r) => r.w < view.width) .sort((a, b) => a.x - b.x); // This room is closed, so every column of the picture is wall: the runs // start at the left edge, meet end to end and reach the right one. That is // the same picture the old one-fill-a-column loop drew. expect(walls[0]!.x).toBe(0); let reach = 0; for (const rect of walls) { expect(rect.x).toBeLessThanOrEqual(reach); reach = Math.max(reach, rect.x + rect.w); } expect(reach).toBeGreaterThanOrEqual(view.width); // And it costs a fraction of the fills: one a column would be 900. expect(walls.length).toBeLessThan(view.width / 2); }); test("a body in the open is drawn; the same body behind the pillar is not", () => { const open = fakeContext(); drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view); // The head is the only arc in this scene, so it stands for the body. expect(open.ops.some((o) => o.op === "arc")).toBe(true); const hidden = fakeContext(); drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view); expect(hidden.ops.some((o) => o.op === "arc")).toBe(false); }); test("a team mate gets a name tag and an enemy never does", () => { const mate = fakeContext(); drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true); const enemy = fakeContext(); drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); }); test("the dead lie on the floor and wear no markers at all", () => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view); // Flat on the ground: an ellipse, not an upright body with a head. expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true); // No name tag, and no chevron — the chevron is the only closed path here. expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); }); test("an enemy wears a chevron and a team mate does not", () => { const enemy = fakeContext(); drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true); const mate = fakeContext(); drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); expect(mate.ops.some((o) => o.op === "closePath")).toBe(false); expect(mate.ops.some((o) => o.op === "fillText")).toBe(true); }); test("a body has a hard dark edge, whichever side it is on", () => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view); expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true); }); // ------------------------------------------- coming into sight, and leaving test("a body arriving is drawn faded and small, and solid once it is in", () => { const edgeOf = (ctx: ReturnType) => ctx.ops.find((o) => o.op === "strokeRect")!; const arriving = fakeContext(); drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 }); const solid = fakeContext(); drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 }); expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5); expect(edgeOf(solid).alpha).toBe(1); // And smaller: it grows from its feet as it comes in. expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2])); expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7); }); test("a body the presence store has only just seen is the one being faded", () => { const seen = makePresence(); const them = body(); sighted(seen, [them], 100); const draw = (at: number) => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), { ...view, shown: (p: { id: string }) => shown(seen, p.id, at), }); return ctx.ops.find((o) => o.op === "strokeRect")!; }; expect(draw(100.03).alpha).toBeGreaterThan(0); expect(draw(100.03).alpha).toBeLessThan(1); expect(draw(100.5).alpha).toBe(1); }); test("a last-seen mark is an outline, dimming, and never a body", () => { const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }]; const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); const edge = ctx.ops.find((o) => o.op === "strokeRect"); expect(edge).toBeDefined(); expect(edge!.alpha).toBeCloseTo(0.6, 5); // No head, no chevron, no name: a memory of a sighting, not a sighting. expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); expect(GHOST).toBeGreaterThan(0); }); test("a body that steps out of sight leaves the outline the store hands over", () => { const seen = makePresence(); const them = body(); sighted(seen, [them], 100); // in sight, in the open sighted(seen, [], 100.1); // and gone behind the corner const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks: ghosts(seen, 100.1), }); // Nobody is in the snapshot at all, and there is still something there. const edge = ctx.ops.find((o) => o.op === "strokeRect"); expect(edge).toBeDefined(); expect(edge!.alpha).toBeCloseTo(1, 2); // A second later the store hands over nothing, and nothing is drawn. const after = fakeContext(); drawScene(after as never, makeScene(), world, eye, snapshot([]), { ...view, marks: ghosts(seen, 100.1 + GHOST), }); expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false); }); test("a mark where the wall now stands between you and it is not drawn", () => { const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }]; const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false); }); // ------------------------------------------------------- colour, by side /** The fill of each upright body, left to right: the rect its edge is drawn round. */ function skins(ctx: ReturnType): string[] { const out: { x: number; fill: string }[] = []; ctx.ops.forEach((o, i) => { if (o.op !== "strokeRect") return; const body = ctx.ops[i - 1]!; if (body.op === "fillRect") out.push({ x: Number(body.args[0]), fill: body.fill }); }); return out.sort((a, b) => a.x - b.x).map((b) => b.fill); } test("without `side`, a mate and an enemy wear exactly today's colours", () => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ id: "m", mate: true, y: 150 }), body({ id: "e", y: 250 })]), view); // Looking east, the smaller y is on the left. expect(skins(ctx)).toEqual(["#3f9fd6", "#d8483f"]); // And a last-seen enemy is outlined in today's colour too. const marked = fakeContext(); drawScene(marked as never, makeScene(), world, eye, snapshot([]), { ...view, marks: [{ id: "e", x: 280, y: 200, angle: 0, mate: false, fade: 1 }], }); expect(marked.ops.find((o) => o.op === "strokeRect")!.stroke).toBe("rgba(235,150,120,0.9)"); }); test("three enemy sides draw in three colours, and each keeps its colour next frame", () => { const three = [ body({ id: "a", side: "scene-north", y: 130 }), body({ id: "b", side: "scene-east", y: 200 }), body({ id: "c", side: "scene-west", y: 270 }), ]; const first = fakeContext(); drawScene(first as never, makeScene(), world, eye, snapshot(three), view); const colours = skins(first); expect(colours).toHaveLength(3); expect(new Set(colours).size).toBe(3); // None of them is the mate's blue. expect(colours).not.toContain("#3f9fd6"); // Next frame the server lists them the other way round: same colours. const second = fakeContext(); drawScene(second as never, makeScene(), world, eye, snapshot([...three].reverse()), view); expect(skins(second)).toEqual(colours); // And a side alone on screen still wears its own, not the first slot's. const alone = fakeContext(); drawScene(alone as never, makeScene(), world, eye, snapshot([three[2]]), view); expect(skins(alone)).toEqual([colours[2]!]); }); test("a mate with a side is still the mate's colour, and the dead are still grey", () => { const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, side: "scene-mine" })]), view); expect(skins(ctx)).toEqual(["#3f9fd6"]); const dead = fakeContext(); drawScene(dead as never, makeScene(), world, eye, snapshot([body({ side: "scene-dead", alive: false, hp: 0 })]), view); expect(dead.ops.find((o) => o.op === "fill")!.fill).toBe("#4a4e57"); });