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

forked from benjivers/val

workbenchthis machine
Files edbb432768e43870332e96f00764183b016fcfde
231 lines · 8.7 KB · ~2.5k 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: one filled column per screen column, and the horizon", () => {
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 two bands and the horizon, then a slice for every column of the wall.
96
  expect(rects.length).toBeGreaterThan(800);
97
});
98
99
test("a body in the open is drawn; the same body behind the pillar is not", () => {
100
  const open = fakeContext();
101
  drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view);
102
  // The head is the only arc in this scene, so it stands for the body.
103
  expect(open.ops.some((o) => o.op === "arc")).toBe(true);
104
105
  const hidden = fakeContext();
106
  drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view);
107
  expect(hidden.ops.some((o) => o.op === "arc")).toBe(false);
108
});
109
110
test("a team mate gets a name tag and an enemy never does", () => {
111
  const mate = fakeContext();
112
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
113
  expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true);
114
115
  const enemy = fakeContext();
116
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
117
  expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
118
});
119
120
test("the dead lie on the floor and wear no markers at all", () => {
121
  const ctx = fakeContext();
122
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
123
  // Flat on the ground: an ellipse, not an upright body with a head.
124
  expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
125
  // No name tag, and no chevron — the chevron is the only closed path here.
126
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
127
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
128
});
129
130
test("an enemy wears a chevron and a team mate does not", () => {
131
  const enemy = fakeContext();
132
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
133
  expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
134
135
  const mate = fakeContext();
136
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
137
  expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
138
  expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
139
});
140
141
test("a body has a hard dark edge, whichever side it is on", () => {
142
  const ctx = fakeContext();
143
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
144
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
145
});
146
147
test("a smoke between the eye and a body hides it", () => {
148
  const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
149
  const ctx = fakeContext();
150
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
151
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
152
153
  // A smoke that is not on the line does not hide anything.
154
  const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
155
  const clear = fakeContext();
156
  drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
157
  expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
158
});
159
160
test("every kind of zone, the spike and a tracer draw without falling over", () => {
161
  const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
162
    k,
163
    x: 300 + i * 20,
164
    y: 200,
165
    r: 90,
166
    f: k === "flash" ? 0.3 : 0,
167
    l: 5,
168
  }));
169
  const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
170
  snap.orbs = [{ x: 360, y: 200 }] as never;
171
  snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
172
  snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
173
  const ctx = fakeContext();
174
  drawScene(ctx as never, makeScene(), world, eye, snap, view);
175
  expect(ctx.ops.length).toBeGreaterThan(100);
176
  expect(ctx.globalAlpha).toBe(1);
177
});
178
179
test("the top-down view draws the real map, small or large", () => {
180
  const map = { rows: gridRows(), tile: TILE };
181
  const snap = snapshot([body({ mate: true })]);
182
  const big = fakeContext();
183
  drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
184
  const small = fakeContext();
185
  drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
186
  expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
187
  expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
188
  expect(small.ops.some((o) => o.op === "translate")).toBe(true);
189
});
190
191
test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
192
  const map = { rows: gridRows(), tile: TILE };
193
  const box = { x: 0, y: 0, w: 900, h: 570 };
194
  const look = (p: never) => p;
195
196
  const mate = fakeContext();
197
  drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
198
  // Two strokes through the dot, and no dot: a cross is not a live player.
199
  expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
200
  expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
201
202
  const enemy = fakeContext();
203
  drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
204
  const empty = fakeContext();
205
  drawTopdown(empty as never, map, snapshot([]), look, box);
206
  expect(enemy.ops.length).toBe(empty.ops.length);
207
});
208
209
test("a living team mate carries their initial on the big map", () => {
210
  const map = { rows: gridRows(), tile: TILE };
211
  const big = fakeContext();
212
  drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
213
    x: 0,
214
    y: 0,
215
    w: 900,
216
    h: 570,
217
  });
218
  expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
219
});
220
221
test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
222
  const gun = makeGun();
223
  const ctx = fakeContext();
224
  drawGun(ctx as never, gun, 900, 570, snapshot([]));
225
  const quiet = ctx.ops.length;
226
  gun.mark = 1;
227
  gun.kick = 1;
228
  const loud = fakeContext();
229
  drawGun(loud as never, gun, 900, 570, snapshot([]));
230
  expect(loud.ops.length).toBeGreaterThan(quiet);
231
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts