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

forked from benjivers/val

workbenchthis machine
Files 0da7ffcb37d59bc652a0ef6ea8768f5a4ee103ff
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 engine's half: walls, bodies, marks, arrivals. What a game puts
7
 * on the floor besides is drawn through hooks and tested with the game.
8
 */
9
10
import { expect, test } from "bun:test";
11
import { GHOST, ghosts, makePresence, shown, sighted } from "../../static/engine/presence.js";
12
import { drawScene, makeScene } from "../../static/engine/scene.js";
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 TILE = 80;
63
const world = { solid: room, tile: TILE };
64
65
// What the engine's renderer needs of a snapshot: you, who you can see,
66
// and the shots you were let see. Nothing of the game.
67
const snapshot = (players: unknown[]) => ({
68
  me: { id: "me", x: 120, y: 200, angle: 0, alive: true, hp: 100 },
69
  players,
70
  shots: [],
71
});
72
73
const body = (over: Record<string, unknown> = {}) => ({
74
  id: "them",
75
  name: "Sable",
76
  x: 280,
77
  y: 200,
78
  angle: 3.1,
79
  hp: 100,
80
  alive: true,
81
  mate: false,
82
  ...over,
83
});
84
85
const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
86
const eye = { x: 120, y: 200, angle: 0 };
87
88
test("a frame draws walls in runs: the whole width covered, far fewer fills", () => {
89
  const ctx = fakeContext();
90
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
91
  const rects = ctx.ops.filter((o) => o.op === "fillRect");
92
  // The bands are the full width; the rest are wall runs.
93
  const walls = rects
94
    .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) }))
95
    .filter((r) => r.w < view.width)
96
    .sort((a, b) => a.x - b.x);
97
  // This room is closed, so every column of the picture is wall: the runs
98
  // start at the left edge, meet end to end and reach the right one. That is
99
  // the same picture the old one-fill-a-column loop drew.
100
  expect(walls[0]!.x).toBe(0);
101
  let reach = 0;
102
  for (const rect of walls) {
103
    expect(rect.x).toBeLessThanOrEqual(reach);
104
    reach = Math.max(reach, rect.x + rect.w);
105
  }
106
  expect(reach).toBeGreaterThanOrEqual(view.width);
107
  // And it costs a fraction of the fills: one a column would be 900.
108
  expect(walls.length).toBeLessThan(view.width / 2);
109
});
110
111
test("a body in the open is drawn; the same body behind the pillar is not", () => {
112
  const open = fakeContext();
113
  drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view);
114
  // The head is the only arc in this scene, so it stands for the body.
115
  expect(open.ops.some((o) => o.op === "arc")).toBe(true);
116
117
  const hidden = fakeContext();
118
  drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view);
119
  expect(hidden.ops.some((o) => o.op === "arc")).toBe(false);
120
});
121
122
test("a team mate gets a name tag and an enemy never does", () => {
123
  const mate = fakeContext();
124
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
125
  expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true);
126
127
  const enemy = fakeContext();
128
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
129
  expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
130
});
131
132
test("the dead lie on the floor and wear no markers at all", () => {
133
  const ctx = fakeContext();
134
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
135
  // Flat on the ground: an ellipse, not an upright body with a head.
136
  expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
137
  // No name tag, and no chevron — the chevron is the only closed path here.
138
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
139
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
140
});
141
142
test("an enemy wears a chevron and a team mate does not", () => {
143
  const enemy = fakeContext();
144
  drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
145
  expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
146
147
  const mate = fakeContext();
148
  drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
149
  expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
150
  expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
151
});
152
153
test("a body has a hard dark edge, whichever side it is on", () => {
154
  const ctx = fakeContext();
155
  drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
156
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
157
});
158
159
// ------------------------------------------- coming into sight, and leaving
160
161
test("a body arriving is drawn faded and small, and solid once it is in", () => {
162
  const edgeOf = (ctx: ReturnType<typeof fakeContext>) => ctx.ops.find((o) => o.op === "strokeRect")!;
163
164
  const arriving = fakeContext();
165
  drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 });
166
  const solid = fakeContext();
167
  drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 });
168
169
  expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5);
170
  expect(edgeOf(solid).alpha).toBe(1);
171
  // And smaller: it grows from its feet as it comes in.
172
  expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2]));
173
  expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7);
174
});
175
176
test("a body the presence store has only just seen is the one being faded", () => {
177
  const seen = makePresence();
178
  const them = body();
179
  sighted(seen, [them], 100);
180
  const draw = (at: number) => {
181
    const ctx = fakeContext();
182
    drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), {
183
      ...view,
184
      shown: (p: { id: string }) => shown(seen, p.id, at),
185
    });
186
    return ctx.ops.find((o) => o.op === "strokeRect")!;
187
  };
188
  expect(draw(100.03).alpha).toBeGreaterThan(0);
189
  expect(draw(100.03).alpha).toBeLessThan(1);
190
  expect(draw(100.5).alpha).toBe(1);
191
});
192
193
test("a last-seen mark is an outline, dimming, and never a body", () => {
194
  const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }];
195
  const ctx = fakeContext();
196
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
197
  const edge = ctx.ops.find((o) => o.op === "strokeRect");
198
  expect(edge).toBeDefined();
199
  expect(edge!.alpha).toBeCloseTo(0.6, 5);
200
  // No head, no chevron, no name: a memory of a sighting, not a sighting.
201
  expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
202
  expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
203
  expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
204
  expect(GHOST).toBeGreaterThan(0);
205
});
206
207
test("a body that steps out of sight leaves the outline the store hands over", () => {
208
  const seen = makePresence();
209
  const them = body();
210
  sighted(seen, [them], 100); // in sight, in the open
211
  sighted(seen, [], 100.1); // and gone behind the corner
212
  const ctx = fakeContext();
213
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), {
214
    ...view,
215
    marks: ghosts(seen, 100.1),
216
  });
217
  // Nobody is in the snapshot at all, and there is still something there.
218
  const edge = ctx.ops.find((o) => o.op === "strokeRect");
219
  expect(edge).toBeDefined();
220
  expect(edge!.alpha).toBeCloseTo(1, 2);
221
222
  // A second later the store hands over nothing, and nothing is drawn.
223
  const after = fakeContext();
224
  drawScene(after as never, makeScene(), world, eye, snapshot([]), {
225
    ...view,
226
    marks: ghosts(seen, 100.1 + GHOST),
227
  });
228
  expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false);
229
});
230
231
test("a mark where the wall now stands between you and it is not drawn", () => {
232
  const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }];
233
  const ctx = fakeContext();
234
  drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
235
  expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false);
236
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts