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

Files 42b76dcb841680461f654961fcecac06b27bd342
246 lines · 9.3 KB · ~2.7k tok Raw History
1
/**
2
 * The renderer, run for real against a canvas that writes down what it was
3
 * asked to draw. No browser, but every line of the draw path is executed —
4
 * which is what catches a typo in a frame nobody can see from a unit test.
5
 */
6
7
import { expect, test } from "bun:test";
8
import { drawGun, makeGun } from "../static/gun.js";
9
import { drawScene, makeScene } from "../static/scene.js";
10
import { drawTopdown } from "../static/topdown.js";
11
import { gridRows, TILE } from "./map.ts";
12
13
type Op = { op: string; args: unknown[] };
14
15
/** A canvas that records. Every call the renderer makes has to exist here. */
16
function fakeContext() {
17
  const ops: Op[] = [];
18
  const note =
19
    (op: string) =>
20
    (...args: unknown[]) => {
21
      ops.push({ op, args });
22
    };
23
  const ctx = {
24
    ops,
25
    fillStyle: "",
26
    strokeStyle: "",
27
    lineWidth: 1,
28
    font: "",
29
    textAlign: "left",
30
    globalAlpha: 1,
31
    clearRect: note("clearRect"),
32
    fillRect: note("fillRect"),
33
    strokeRect: note("strokeRect"),
34
    beginPath: note("beginPath"),
35
    closePath: note("closePath"),
36
    moveTo: note("moveTo"),
37
    lineTo: note("lineTo"),
38
    arc: note("arc"),
39
    ellipse: note("ellipse"),
40
    fill: note("fill"),
41
    stroke: note("stroke"),
42
    clip: note("clip"),
43
    rect: note("rect"),
44
    save: note("save"),
45
    restore: note("restore"),
46
    translate: note("translate"),
47
    rotate: note("rotate"),
48
    fillText: note("fillText"),
49
    measureText: (text: string) => ({ width: text.length * 6 }),
50
    createLinearGradient: () => ({ addColorStop() {} }),
51
  };
52
  return ctx;
53
}
54
55
// A room three tiles deep running east, with a pillar standing in it at
56
// column 5 — enough geometry for a body to hide behind.
57
const room = (col: number, row: number): boolean =>
58
  col < 1 || col > 12 || row < 1 || row > 3 || col === 5;
59
const world = { solid: room, tile: TILE };
60
61
const snapshot = (players: unknown[], zones: unknown[] = []) => ({
62
  phase: "live",
63
  clock: 40,
64
  result: null,
65
  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" },
66
  players,
67
  zones,
68
  orbs: [],
69
  shots: [],
70
  spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null },
71
});
72
73
const body = (over: Record<string, unknown> = {}) => ({
74
  id: "them",
75
  name: "Sable",
76
  agent: "sable",
77
  x: 280,
78
  y: 200,
79
  angle: 3.1,
80
  hp: 100,
81
  alive: true,
82
  mate: false,
83
  spike: false,
84
  blind: false,
85
  ...over,
86
});
87
88
const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
89
const eye = { x: 120, y: 200, angle: 0 };
90
91
test("a frame draws walls in runs: the whole width covered, far fewer fills", () => {
92
  const ctx = fakeContext();
93
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
94
  const rects = ctx.ops.filter((o) => o.op === "fillRect");
95
  // The bands are the full width; the rest are wall runs.
96
  const walls = rects
97
    .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) }))
98
    .filter((r) => r.w < view.width)
99
    .sort((a, b) => a.x - b.x);
100
  // This room is closed, so every column of the picture is wall: the runs
101
  // start at the left edge, meet end to end and reach the right one. That is
102
  // the same picture the old one-fill-a-column loop drew.
103
  expect(walls[0]!.x).toBe(0);
104
  let reach = 0;
105
  for (const rect of walls) {
106
    expect(rect.x).toBeLessThanOrEqual(reach);
107
    reach = Math.max(reach, rect.x + rect.w);
108
  }
109
  expect(reach).toBeGreaterThanOrEqual(view.width);
110
  // And it costs a fraction of the fills: one a column would be 900.
111
  expect(walls.length).toBeLessThan(view.width / 2);
112
});
113
114
test("a body in the open is drawn; the same body behind the pillar is not", () => {
115
  const open = fakeContext();
116
  drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view);
117
  // The head is the only arc in this scene, so it stands for the body.
118
  expect(open.ops.some((o) => o.op === "arc")).toBe(true);
119
120
  const hidden = fakeContext();
121
  drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view);
122
  expect(hidden.ops.some((o) => o.op === "arc")).toBe(false);
123
});
124
125
test("a team mate gets a name tag and an enemy never does", () => {
126
  const mate = fakeContext();
127
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
128
  expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true);
129
130
  const enemy = fakeContext();
131
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
132
  expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
133
});
134
135
test("the dead lie on the floor and wear no markers at all", () => {
136
  const ctx = fakeContext();
137
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
138
  // Flat on the ground: an ellipse, not an upright body with a head.
139
  expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
140
  // No name tag, and no chevron — the chevron is the only closed path here.
141
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
142
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
143
});
144
145
test("an enemy wears a chevron and a team mate does not", () => {
146
  const enemy = fakeContext();
147
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
148
  expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
149
150
  const mate = fakeContext();
151
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
152
  expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
153
  expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
154
});
155
156
test("a body has a hard dark edge, whichever side it is on", () => {
157
  const ctx = fakeContext();
158
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
159
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
160
});
161
162
test("a smoke between the eye and a body hides it", () => {
163
  const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
164
  const ctx = fakeContext();
165
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
166
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
167
168
  // A smoke that is not on the line does not hide anything.
169
  const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
170
  const clear = fakeContext();
171
  drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
172
  expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
173
});
174
175
test("every kind of zone, the spike and a tracer draw without falling over", () => {
176
  const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
177
    k,
178
    x: 300 + i * 20,
179
    y: 200,
180
    r: 90,
181
    f: k === "flash" ? 0.3 : 0,
182
    l: 5,
183
  }));
184
  const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
185
  snap.orbs = [{ x: 360, y: 200 }] as never;
186
  snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
187
  snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
188
  const ctx = fakeContext();
189
  drawScene(ctx as never, makeScene(), world, eye, snap, view);
190
  expect(ctx.ops.length).toBeGreaterThan(100);
191
  expect(ctx.globalAlpha).toBe(1);
192
});
193
194
test("the top-down view draws the real map, small or large", () => {
195
  const map = { rows: gridRows(), tile: TILE };
196
  const snap = snapshot([body({ mate: true })]);
197
  const big = fakeContext();
198
  drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
199
  const small = fakeContext();
200
  drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
201
  expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
202
  expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
203
  expect(small.ops.some((o) => o.op === "translate")).toBe(true);
204
});
205
206
test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
207
  const map = { rows: gridRows(), tile: TILE };
208
  const box = { x: 0, y: 0, w: 900, h: 570 };
209
  const look = (p: never) => p;
210
211
  const mate = fakeContext();
212
  drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
213
  // Two strokes through the dot, and no dot: a cross is not a live player.
214
  expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
215
  expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
216
217
  const enemy = fakeContext();
218
  drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
219
  const empty = fakeContext();
220
  drawTopdown(empty as never, map, snapshot([]), look, box);
221
  expect(enemy.ops.length).toBe(empty.ops.length);
222
});
223
224
test("a living team mate carries their initial on the big map", () => {
225
  const map = { rows: gridRows(), tile: TILE };
226
  const big = fakeContext();
227
  drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
228
    x: 0,
229
    y: 0,
230
    w: 900,
231
    h: 570,
232
  });
233
  expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
234
});
235
236
test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
237
  const gun = makeGun();
238
  const ctx = fakeContext();
239
  drawGun(ctx as never, gun, 900, 570, snapshot([]));
240
  const quiet = ctx.ops.length;
241
  gun.mark = 1;
242
  gun.kick = 1;
243
  const loud = fakeContext();
244
  drawGun(loud as never, gun, 900, 570, snapshot([]));
245
  expect(loud.ops.length).toBeGreaterThan(quiet);
246
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts