/** * 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 }; /** 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 }); }; 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); });