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

forked from benjivers/val

workbenchthis machine
Files 984794ed22fb89fd183fee6ad0e3c1184486a759
200 lines · 7.3 KB · ~2.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
 * This is the game's half: the spike, the orbs, the zones, the real map and
7
 * the gun. The walls and the bodies are proved in `engine/scene.test.ts`.
8
 */
9
10
import { expect, test } from "bun:test";
11
import { drawGun, makeGun } from "../static/engine/gun.js";
12
import { drawScene, makeScene } from "../static/scene.js";
13
import { drawTopdown } from "../static/topdown.js";
14
import { gridRows, TILE } from "./map.ts";
15
16
type Op = { op: string; args: unknown[]; alpha: number };
17
18
/** A canvas that records. Every call the renderer makes has to exist here. */
19
function fakeContext() {
20
  const ops: Op[] = [];
21
  const note =
22
    (op: string) =>
23
    (...args: unknown[]) => {
24
      // The opacity in force when the call was made: how faded a body
25
      // arriving into sight is, and how far a last-seen mark has dimmed.
26
      ops.push({ op, args, alpha: ctx.globalAlpha });
27
    };
28
  const ctx = {
29
    ops,
30
    fillStyle: "",
31
    strokeStyle: "",
32
    lineWidth: 1,
33
    font: "",
34
    textAlign: "left",
35
    globalAlpha: 1,
36
    clearRect: note("clearRect"),
37
    fillRect: note("fillRect"),
38
    strokeRect: note("strokeRect"),
39
    beginPath: note("beginPath"),
40
    closePath: note("closePath"),
41
    moveTo: note("moveTo"),
42
    lineTo: note("lineTo"),
43
    arc: note("arc"),
44
    ellipse: note("ellipse"),
45
    fill: note("fill"),
46
    stroke: note("stroke"),
47
    clip: note("clip"),
48
    rect: note("rect"),
49
    save: note("save"),
50
    restore: note("restore"),
51
    translate: note("translate"),
52
    rotate: note("rotate"),
53
    fillText: note("fillText"),
54
    measureText: (text: string) => ({ width: text.length * 6 }),
55
    createLinearGradient: () => ({ addColorStop() {} }),
56
  };
57
  return ctx;
58
}
59
60
// A room three tiles deep running east, with a pillar standing in it at
61
// column 5 — enough geometry for a body to hide behind.
62
const room = (col: number, row: number): boolean =>
63
  col < 1 || col > 12 || row < 1 || row > 3 || col === 5;
64
const world = { solid: room, tile: TILE };
65
66
const snapshot = (players: unknown[], zones: unknown[] = []) => ({
67
  phase: "live",
68
  clock: 40,
69
  result: null,
70
  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" },
71
  players,
72
  zones,
73
  orbs: [],
74
  shots: [],
75
  spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null },
76
});
77
78
const body = (over: Record<string, unknown> = {}) => ({
79
  id: "them",
80
  name: "Sable",
81
  agent: "sable",
82
  x: 280,
83
  y: 200,
84
  angle: 3.1,
85
  hp: 100,
86
  alive: true,
87
  mate: false,
88
  spike: false,
89
  blind: false,
90
  ...over,
91
});
92
93
const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
94
const eye = { x: 120, y: 200, angle: 0 };
95
96
test("a smoke between the eye and a body hides it", () => {
97
  const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
98
  const ctx = fakeContext();
99
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
100
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
101
102
  // A smoke that is not on the line does not hide anything.
103
  const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
104
  const clear = fakeContext();
105
  drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
106
  expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
107
});
108
109
test("every kind of zone, the spike and a tracer draw without falling over", () => {
110
  const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
111
    k,
112
    x: 300 + i * 20,
113
    y: 200,
114
    r: 90,
115
    f: k === "flash" ? 0.3 : 0,
116
    l: 5,
117
  }));
118
  const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
119
  snap.orbs = [{ x: 360, y: 200 }] as never;
120
  snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
121
  snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
122
  const ctx = fakeContext();
123
  drawScene(ctx as never, makeScene(), world, eye, snap, view);
124
  expect(ctx.ops.length).toBeGreaterThan(100);
125
  expect(ctx.globalAlpha).toBe(1);
126
});
127
128
test("the top-down view draws the real map, small or large", () => {
129
  const map = { rows: gridRows(), tile: TILE };
130
  const snap = snapshot([body({ mate: true })]);
131
  const big = fakeContext();
132
  drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
133
  const small = fakeContext();
134
  drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
135
  expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
136
  expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
137
  expect(small.ops.some((o) => o.op === "translate")).toBe(true);
138
});
139
140
test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
141
  const map = { rows: gridRows(), tile: TILE };
142
  const box = { x: 0, y: 0, w: 900, h: 570 };
143
  const look = (p: never) => p;
144
145
  const mate = fakeContext();
146
  drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
147
  // Two strokes through the dot, and no dot: a cross is not a live player.
148
  expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
149
  expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
150
151
  const enemy = fakeContext();
152
  drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
153
  const empty = fakeContext();
154
  drawTopdown(empty as never, map, snapshot([]), look, box);
155
  expect(enemy.ops.length).toBe(empty.ops.length);
156
});
157
158
test("a living team mate carries their initial on the big map", () => {
159
  const map = { rows: gridRows(), tile: TILE };
160
  const big = fakeContext();
161
  drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
162
    x: 0,
163
    y: 0,
164
    w: 900,
165
    h: 570,
166
  });
167
  expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
168
});
169
170
test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
171
  const gun = makeGun();
172
  const ctx = fakeContext();
173
  drawGun(ctx as never, gun, 900, 570, snapshot([]));
174
  const quiet = ctx.ops.length;
175
  gun.mark = 1;
176
  gun.kick = 1;
177
  const loud = fakeContext();
178
  drawGun(loud as never, gun, 900, 570, snapshot([]));
179
  expect(loud.ops.length).toBeGreaterThan(quiet);
180
});
181
182
test("on the map a last-seen ring is hollow, and dims with the mark", () => {
183
  const map = { rows: gridRows(), tile: TILE };
184
  const box = { x: 0, y: 0, w: 900, h: 570 };
185
  const look = (p: never) => p;
186
  const marks = [{ id: "them", x: 400, y: 400, angle: 0, mate: false, fade: 0.5 }];
187
188
  const plain = fakeContext();
189
  drawTopdown(plain as never, map, snapshot([]), look, box);
190
  const marked = fakeContext();
191
  drawTopdown(marked as never, map, snapshot([]), look, box, { marks });
192
193
  const count = (ctx: ReturnType<typeof fakeContext>, op: string) => ctx.ops.filter((o) => o.op === op).length;
194
  // Hollow: the ring adds strokes and not one fill.
195
  expect(count(marked, "fill")).toBe(count(plain, "fill"));
196
  expect(count(marked, "stroke")).toBeGreaterThan(count(plain, "stroke"));
197
  const ring = marked.ops.find((o) => o.op === "arc")!;
198
  expect(ring).toBeDefined();
199
  expect(ring.alpha).toBeCloseTo(0.4, 5);
200
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts