/** * 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 game's half: the spike, the orbs, the zones, the real map and * the gun. The walls and the bodies are proved in `engine/scene.test.ts`. */ import { expect, test } from "bun:test"; import { drawGun, makeGun } from "../static/engine/gun.js"; import { drawScene, makeScene } from "../static/scene.js"; import { drawTopdown } from "../static/topdown.js"; import { gridRows, TILE } from "./map.ts"; 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 world = { solid: room, tile: TILE }; const snapshot = (players: unknown[], zones: unknown[] = []) => ({ phase: "live", clock: 40, result: null, me: { id: "me", x: 120, y: 200, angle: 0, alive: true, side: "atk", hp: 100, blind: 0, ammo: 25, reloading: false, planting: 0, spike: false, agent: "ember" }, players, zones, orbs: [], shots: [], spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null }, }); const body = (over: Record = {}) => ({ id: "them", name: "Sable", agent: "sable", x: 280, y: 200, angle: 3.1, hp: 100, alive: true, mate: false, spike: false, blind: 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 smoke between the eye and a body hides it", () => { const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }]; const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view); expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); // A smoke that is not on the line does not hide anything. const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }]; const clear = fakeContext(); drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view); expect(clear.ops.some((o) => o.op === "arc")).toBe(true); }); test("every kind of zone, the spike and a tracer draw without falling over", () => { const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({ k, x: 300 + i * 20, y: 200, r: 90, f: k === "flash" ? 0.3 : 0, l: 5, })); const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones); snap.orbs = [{ x: 360, y: 200 }] as never; snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never; snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never; const ctx = fakeContext(); drawScene(ctx as never, makeScene(), world, eye, snap, view); expect(ctx.ops.length).toBeGreaterThan(100); expect(ctx.globalAlpha).toBe(1); }); test("the top-down view draws the real map, small or large", () => { const map = { rows: gridRows(), tile: TILE }; const snap = snapshot([body({ mate: true })]); const big = fakeContext(); drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 }); const small = fakeContext(); drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false }); expect(big.ops.some((o) => o.op === "fillText")).toBe(true); expect(small.ops.some((o) => o.op === "fillText")).toBe(false); expect(small.ops.some((o) => o.op === "translate")).toBe(true); }); test("on the map a dead team mate is crossed out and a dead enemy is not there", () => { const map = { rows: gridRows(), tile: TILE }; const box = { x: 0, y: 0, w: 900, h: 570 }; const look = (p: never) => p; const mate = fakeContext(); drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box); // Two strokes through the dot, and no dot: a cross is not a live player. expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2); expect(mate.ops.some((o) => o.op === "arc")).toBe(false); const enemy = fakeContext(); drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box); const empty = fakeContext(); drawTopdown(empty as never, map, snapshot([]), look, box); expect(enemy.ops.length).toBe(empty.ops.length); }); test("a living team mate carries their initial on the big map", () => { const map = { rows: gridRows(), tile: TILE }; const big = fakeContext(); drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, { x: 0, y: 0, w: 900, h: 570, }); expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true); }); test("the gun and the crosshair draw, and the hit marker only when there is one", () => { const gun = makeGun(); const ctx = fakeContext(); drawGun(ctx as never, gun, 900, 570, snapshot([])); const quiet = ctx.ops.length; gun.mark = 1; gun.kick = 1; const loud = fakeContext(); drawGun(loud as never, gun, 900, 570, snapshot([])); expect(loud.ops.length).toBeGreaterThan(quiet); }); test("on the map a last-seen ring is hollow, and dims with the mark", () => { const map = { rows: gridRows(), tile: TILE }; const box = { x: 0, y: 0, w: 900, h: 570 }; const look = (p: never) => p; const marks = [{ id: "them", x: 400, y: 400, angle: 0, mate: false, fade: 0.5 }]; const plain = fakeContext(); drawTopdown(plain as never, map, snapshot([]), look, box); const marked = fakeContext(); drawTopdown(marked as never, map, snapshot([]), look, box, { marks }); const count = (ctx: ReturnType, op: string) => ctx.ops.filter((o) => o.op === op).length; // Hollow: the ring adds strokes and not one fill. expect(count(marked, "fill")).toBe(count(plain, "fill")); expect(count(marked, "stroke")).toBeGreaterThan(count(plain, "stroke")); const ring = marked.ops.find((o) => o.op === "arc")!; expect(ring).toBeDefined(); expect(ring.alpha).toBeCloseTo(0.4, 5); });