quill/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files b02c1ef0dc92246d3da3567c343126686c5677c3
348 lines · 13.9 KB · ~4.1k 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 { GHOST, ghosts, makePresence, shown, sighted } from "../static/presence.js";
10
import { drawScene, makeScene } from "../static/scene.js";
11
import { drawTopdown } from "../static/topdown.js";
12
import { gridRows, TILE } from "./map.ts";
13
14
type Op = { op: string; args: unknown[]; alpha: number };
15
16
/** A canvas that records. Every call the renderer makes has to exist here. */
17
function fakeContext() {
18
  const ops: Op[] = [];
19
  const note =
20
    (op: string) =>
21
    (...args: unknown[]) => {
22
      // The opacity in force when the call was made: how faded a body
23
      // arriving into sight is, and how far a last-seen mark has dimmed.
24
      ops.push({ op, args, alpha: ctx.globalAlpha });
25
    };
26
  const ctx = {
27
    ops,
28
    fillStyle: "",
29
    strokeStyle: "",
30
    lineWidth: 1,
31
    font: "",
32
    textAlign: "left",
33
    globalAlpha: 1,
34
    clearRect: note("clearRect"),
35
    fillRect: note("fillRect"),
36
    strokeRect: note("strokeRect"),
37
    beginPath: note("beginPath"),
38
    closePath: note("closePath"),
39
    moveTo: note("moveTo"),
40
    lineTo: note("lineTo"),
41
    arc: note("arc"),
42
    ellipse: note("ellipse"),
43
    fill: note("fill"),
44
    stroke: note("stroke"),
45
    clip: note("clip"),
46
    rect: note("rect"),
47
    save: note("save"),
48
    restore: note("restore"),
49
    translate: note("translate"),
50
    rotate: note("rotate"),
51
    fillText: note("fillText"),
52
    measureText: (text: string) => ({ width: text.length * 6 }),
53
    createLinearGradient: () => ({ addColorStop() {} }),
54
  };
55
  return ctx;
56
}
57
58
// A room three tiles deep running east, with a pillar standing in it at
59
// column 5 — enough geometry for a body to hide behind.
60
const room = (col: number, row: number): boolean =>
61
  col < 1 || col > 12 || row < 1 || row > 3 || col === 5;
62
const world = { solid: room, tile: TILE };
63
64
const snapshot = (players: unknown[], zones: unknown[] = []) => ({
65
  phase: "live",
66
  clock: 40,
67
  result: null,
68
  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" },
69
  players,
70
  zones,
71
  orbs: [],
72
  shots: [],
73
  spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null },
74
});
75
76
const body = (over: Record<string, unknown> = {}) => ({
77
  id: "them",
78
  name: "Sable",
79
  agent: "sable",
80
  x: 280,
81
  y: 200,
82
  angle: 3.1,
83
  hp: 100,
84
  alive: true,
85
  mate: false,
86
  spike: false,
87
  blind: false,
88
  ...over,
89
});
90
91
const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
92
const eye = { x: 120, y: 200, angle: 0 };
93
94
test("a frame draws walls in runs: the whole width covered, far fewer fills", () => {
95
  const ctx = fakeContext();
96
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
97
  const rects = ctx.ops.filter((o) => o.op === "fillRect");
98
  // The bands are the full width; the rest are wall runs.
99
  const walls = rects
100
    .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) }))
101
    .filter((r) => r.w < view.width)
102
    .sort((a, b) => a.x - b.x);
103
  // This room is closed, so every column of the picture is wall: the runs
104
  // start at the left edge, meet end to end and reach the right one. That is
105
  // the same picture the old one-fill-a-column loop drew.
106
  expect(walls[0]!.x).toBe(0);
107
  let reach = 0;
108
  for (const rect of walls) {
109
    expect(rect.x).toBeLessThanOrEqual(reach);
110
    reach = Math.max(reach, rect.x + rect.w);
111
  }
112
  expect(reach).toBeGreaterThanOrEqual(view.width);
113
  // And it costs a fraction of the fills: one a column would be 900.
114
  expect(walls.length).toBeLessThan(view.width / 2);
115
});
116
117
test("a body in the open is drawn; the same body behind the pillar is not", () => {
118
  const open = fakeContext();
119
  drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view);
120
  // The head is the only arc in this scene, so it stands for the body.
121
  expect(open.ops.some((o) => o.op === "arc")).toBe(true);
122
123
  const hidden = fakeContext();
124
  drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view);
125
  expect(hidden.ops.some((o) => o.op === "arc")).toBe(false);
126
});
127
128
test("a team mate gets a name tag and an enemy never does", () => {
129
  const mate = fakeContext();
130
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
131
  expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true);
132
133
  const enemy = fakeContext();
134
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
135
  expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
136
});
137
138
test("the dead lie on the floor and wear no markers at all", () => {
139
  const ctx = fakeContext();
140
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
141
  // Flat on the ground: an ellipse, not an upright body with a head.
142
  expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
143
  // No name tag, and no chevron — the chevron is the only closed path here.
144
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
145
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
146
});
147
148
test("an enemy wears a chevron and a team mate does not", () => {
149
  const enemy = fakeContext();
150
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
151
  expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
152
153
  const mate = fakeContext();
154
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
155
  expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
156
  expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
157
});
158
159
test("a body has a hard dark edge, whichever side it is on", () => {
160
  const ctx = fakeContext();
161
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
162
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
163
});
164
165
test("a smoke between the eye and a body hides it", () => {
166
  const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
167
  const ctx = fakeContext();
168
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
169
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
170
171
  // A smoke that is not on the line does not hide anything.
172
  const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
173
  const clear = fakeContext();
174
  drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
175
  expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
176
});
177
178
test("every kind of zone, the spike and a tracer draw without falling over", () => {
179
  const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
180
    k,
181
    x: 300 + i * 20,
182
    y: 200,
183
    r: 90,
184
    f: k === "flash" ? 0.3 : 0,
185
    l: 5,
186
  }));
187
  const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
188
  snap.orbs = [{ x: 360, y: 200 }] as never;
189
  snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
190
  snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
191
  const ctx = fakeContext();
192
  drawScene(ctx as never, makeScene(), world, eye, snap, view);
193
  expect(ctx.ops.length).toBeGreaterThan(100);
194
  expect(ctx.globalAlpha).toBe(1);
195
});
196
197
test("the top-down view draws the real map, small or large", () => {
198
  const map = { rows: gridRows(), tile: TILE };
199
  const snap = snapshot([body({ mate: true })]);
200
  const big = fakeContext();
201
  drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
202
  const small = fakeContext();
203
  drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
204
  expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
205
  expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
206
  expect(small.ops.some((o) => o.op === "translate")).toBe(true);
207
});
208
209
test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
210
  const map = { rows: gridRows(), tile: TILE };
211
  const box = { x: 0, y: 0, w: 900, h: 570 };
212
  const look = (p: never) => p;
213
214
  const mate = fakeContext();
215
  drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
216
  // Two strokes through the dot, and no dot: a cross is not a live player.
217
  expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
218
  expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
219
220
  const enemy = fakeContext();
221
  drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
222
  const empty = fakeContext();
223
  drawTopdown(empty as never, map, snapshot([]), look, box);
224
  expect(enemy.ops.length).toBe(empty.ops.length);
225
});
226
227
test("a living team mate carries their initial on the big map", () => {
228
  const map = { rows: gridRows(), tile: TILE };
229
  const big = fakeContext();
230
  drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
231
    x: 0,
232
    y: 0,
233
    w: 900,
234
    h: 570,
235
  });
236
  expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
237
});
238
239
test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
240
  const gun = makeGun();
241
  const ctx = fakeContext();
242
  drawGun(ctx as never, gun, 900, 570, snapshot([]));
243
  const quiet = ctx.ops.length;
244
  gun.mark = 1;
245
  gun.kick = 1;
246
  const loud = fakeContext();
247
  drawGun(loud as never, gun, 900, 570, snapshot([]));
248
  expect(loud.ops.length).toBeGreaterThan(quiet);
249
});
250
251
// ------------------------------------------- coming into sight, and leaving
252
253
test("a body arriving is drawn faded and small, and solid once it is in", () => {
254
  const edgeOf = (ctx: ReturnType<typeof fakeContext>) => ctx.ops.find((o) => o.op === "strokeRect")!;
255
256
  const arriving = fakeContext();
257
  drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 });
258
  const solid = fakeContext();
259
  drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 });
260
261
  expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5);
262
  expect(edgeOf(solid).alpha).toBe(1);
263
  // And smaller: it grows from its feet as it comes in.
264
  expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2]));
265
  expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7);
266
});
267
268
test("a body the presence store has only just seen is the one being faded", () => {
269
  const seen = makePresence();
270
  const them = body();
271
  sighted(seen, [them], 100);
272
  const draw = (at: number) => {
273
    const ctx = fakeContext();
274
    drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), {
275
      ...view,
276
      shown: (p: { id: string }) => shown(seen, p.id, at),
277
    });
278
    return ctx.ops.find((o) => o.op === "strokeRect")!;
279
  };
280
  expect(draw(100.03).alpha).toBeGreaterThan(0);
281
  expect(draw(100.03).alpha).toBeLessThan(1);
282
  expect(draw(100.5).alpha).toBe(1);
283
});
284
285
test("a last-seen mark is an outline, dimming, and never a body", () => {
286
  const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }];
287
  const ctx = fakeContext();
288
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
289
  const edge = ctx.ops.find((o) => o.op === "strokeRect");
290
  expect(edge).toBeDefined();
291
  expect(edge!.alpha).toBeCloseTo(0.6, 5);
292
  // No head, no chevron, no name: a memory of a sighting, not a sighting.
293
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
294
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
295
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
296
  expect(GHOST).toBeGreaterThan(0);
297
});
298
299
test("a body that steps out of sight leaves the outline the store hands over", () => {
300
  const seen = makePresence();
301
  const them = body();
302
  sighted(seen, [them], 100); // in sight, in the open
303
  sighted(seen, [], 100.1); // and gone behind the corner
304
  const ctx = fakeContext();
305
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), {
306
    ...view,
307
    marks: ghosts(seen, 100.1),
308
  });
309
  // Nobody is in the snapshot at all, and there is still something there.
310
  const edge = ctx.ops.find((o) => o.op === "strokeRect");
311
  expect(edge).toBeDefined();
312
  expect(edge!.alpha).toBeCloseTo(1, 2);
313
314
  // A second later the store hands over nothing, and nothing is drawn.
315
  const after = fakeContext();
316
  drawScene(after as never, makeScene(), world, eye, snapshot([]), {
317
    ...view,
318
    marks: ghosts(seen, 100.1 + GHOST),
319
  });
320
  expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false);
321
});
322
323
test("a mark where the wall now stands between you and it is not drawn", () => {
324
  const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }];
325
  const ctx = fakeContext();
326
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
327
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false);
328
});
329
330
test("on the map a last-seen ring is hollow, and dims with the mark", () => {
331
  const map = { rows: gridRows(), tile: TILE };
332
  const box = { x: 0, y: 0, w: 900, h: 570 };
333
  const look = (p: never) => p;
334
  const marks = [{ id: "them", x: 400, y: 400, angle: 0, mate: false, fade: 0.5 }];
335
336
  const plain = fakeContext();
337
  drawTopdown(plain as never, map, snapshot([]), look, box);
338
  const marked = fakeContext();
339
  drawTopdown(marked as never, map, snapshot([]), look, box, { marks });
340
341
  const count = (ctx: ReturnType<typeof fakeContext>, op: string) => ctx.ops.filter((o) => o.op === op).length;
342
  // Hollow: the ring adds strokes and not one fill.
343
  expect(count(marked, "fill")).toBe(count(plain, "fill"));
344
  expect(count(marked, "stroke")).toBeGreaterThan(count(plain, "stroke"));
345
  const ring = marked.ops.find((o) => o.op === "arc")!;
346
  expect(ring).toBeDefined();
347
  expect(ring.alpha).toBeCloseTo(0.4, 5);
348
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts