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

forked from benjivers/val

workbenchthis machine

Bodies walk into sight, and leave a mark where they went

The drawing now uses presence.js. A body in its first tenth of a second is
drawn faded and growing from its feet, and if it was seen near there before
it slides in from the direction it came, so it enters the room rather than
appearing in it. Where somebody was last seen alive stands an outline in the
first-person view and a hollow ring on the map, dimming away over a second —
deliberately not a body: nothing filled, no head, no chevron, no name.

The mark is drawn at the position it was seen at, which means a wall between
you and that spot hides it like anything else, and it is only ever somewhere
the server had already shown you somebody. Nothing extra is sent, and the
vision rule is untouched.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed d5bfb1d Browse files
4 files changed +168 −9
src/scene.test.ts +80 −2
@@ -6,11 +6,12 @@
66
77 import { expect, test } from "bun:test";
88 import { drawGun, makeGun } from "../static/gun.js";
9 import { GHOST, makePresence, shown, sighted } from "../static/presence.js";
910 import { drawScene, makeScene } from "../static/scene.js";
1011 import { drawTopdown } from "../static/topdown.js";
1112 import { gridRows, TILE } from "./map.ts";
1213
13 type Op = { op: string; args: unknown[] };
14 type Op = { op: string; args: unknown[]; alpha: number };
1415
1516 /** A canvas that records. Every call the renderer makes has to exist here. */
1617 function fakeContext() {
@@ -18,7 +19,9 @@ function fakeContext() {
1819 const note =
1920 (op: string) =>
2021 (...args: unknown[]) => {
21 ops.push({ op, args });
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 });
2225 };
2326 const ctx = {
2427 ops,
@@ -244,3 +247,78 @@ test("the gun and the crosshair draw, and the hit marker only when there is one"
244247 drawGun(loud as never, gun, 900, 570, snapshot([]));
245248 expect(loud.ops.length).toBeGreaterThan(quiet);
246249 });
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 mark where the wall now stands between you and it is not drawn", () => {
300 const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }];
301 const ctx = fakeContext();
302 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks });
303 expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false);
304 });
305
306 test("on the map a last-seen ring is hollow, and dims with the mark", () => {
307 const map = { rows: gridRows(), tile: TILE };
308 const box = { x: 0, y: 0, w: 900, h: 570 };
309 const look = (p: never) => p;
310 const marks = [{ id: "them", x: 400, y: 400, angle: 0, mate: false, fade: 0.5 }];
311
312 const plain = fakeContext();
313 drawTopdown(plain as never, map, snapshot([]), look, box);
314 const marked = fakeContext();
315 drawTopdown(marked as never, map, snapshot([]), look, box, { marks });
316
317 const count = (ctx: ReturnType<typeof fakeContext>, op: string) => ctx.ops.filter((o) => o.op === op).length;
318 // Hollow: the ring adds strokes and not one fill.
319 expect(count(marked, "fill")).toBe(count(plain, "fill"));
320 expect(count(marked, "stroke")).toBeGreaterThan(count(plain, "stroke"));
321 const ring = marked.ops.find((o) => o.op === "arc")!;
322 expect(ring).toBeDefined();
323 expect(ring.alpha).toBeCloseTo(0.4, 5);
324 });
static/scene.js +34 −3
@@ -15,6 +15,7 @@
1515 * owns.
1616 */
1717
18 import { swell } from "./presence.js";
1819 import { makeColumns, SKY, wallRuns } from "./slices.js";
1920 import {
2021 castRay,
@@ -196,8 +197,14 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
196197 for (const player of snap.players) {
197198 if (player.id === snap.me.id) continue;
198199 const at = view.look(player);
199 things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle });
200 // How far into sight this body is: 0 the moment it appears, 1 solid.
201 // See presence.js — a body that pops into existence reads as a bug.
202 const shown = view.shown ? view.shown(player) : 1;
203 things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle });
200204 }
205 // Where somebody was last seen, dimming away. It stands where they were,
206 // so a wall between you and that spot hides it like anything else.
207 if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y });
201208
202209 const p = scene.proj;
203210 for (const thing of things) {
@@ -213,9 +220,11 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
213220 if (thing.behind) continue;
214221 const floorY = horizon + (EYE * proj) / thing.perp;
215222 const haze = hazeAt(scene, thing.column, step, columns);
216 ctx.globalAlpha = Math.max(0, 1 - haze);
223 const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1;
224 ctx.globalAlpha = Math.max(0, 1 - haze) * arriving;
217225 if (ctx.globalAlpha < 0.05) continue;
218226 if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns);
227 else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns);
219228 else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns);
220229 else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns);
221230 else drawZone(ctx, scene, thing, floorY, proj, step, columns);
@@ -257,7 +266,9 @@ function withRuns(ctx, scene, left, right, perp, step, columns, draw) {
257266
258267 function drawBody(ctx, scene, thing, floorY, step, columns) {
259268 const p = thing.player;
260 const h = thing.size;
269 // A body arriving grows from its feet as it fades in, so it reads as
270 // somebody coming round the corner rather than one appearing there.
271 const h = thing.size * swell(thing.shown ?? 1);
261272 const w = h * 0.46;
262273 const cx = thing.column;
263274 const top = floorY - h;
@@ -316,6 +327,26 @@ function drawBody(ctx, scene, thing, floorY, step, columns) {
316327 });
317328 }
318329
330 /**
331 * A mark where somebody was last seen: an empty outline and a scuff on the
332 * floor, dimming away over a second. Deliberately not a body — nothing is
333 * filled, there is no head and no chevron — because it is a memory of a
334 * sighting and must never be mistaken for one.
335 */
336 function drawMark(ctx, scene, thing, floorY, step, columns) {
337 const h = thing.size * 0.9;
338 const w = h * 0.46;
339 const cx = thing.column;
340 const top = floorY - h;
341 withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.2, thing.perp, step, columns, () => {
342 ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)";
343 ctx.lineWidth = Math.max(1, h * 0.035);
344 ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
345 ctx.fillStyle = "rgba(10,12,16,0.45)";
346 ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05);
347 });
348 }
349
319350 /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read
320351 * red and blue the same way. */
321352 function chevron(ctx, cx, top, h) {
static/topdown.js +27 −1
@@ -20,7 +20,9 @@ const TILE_COLOUR = {
2020 /**
2121 * Draw the whole map into `box` (canvas pixels). `look(player)` gives the
2222 * smoothed position; `opts.labels` draws site letters and names, which only
23 * fit on the big version.
23 * fit on the big version; `opts.marks` are the last-seen rings from
24 * `presence.js`, which answer "where did they go" without claiming anybody
25 * is there now.
2426 */
2527 export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
2628 const cols = map.rows[0].length;
@@ -79,6 +81,9 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
7981 ctx.stroke();
8082 }
8183
84 // Under the living: a ring where somebody was last seen is never allowed
85 // to sit on top of a body that is actually in sight.
86 if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s);
8287 for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels);
8388 // Smoke last: on this view it hides what is under it, as it should.
8489 for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s);
@@ -115,6 +120,27 @@ function drawZone(ctx, z, s) {
115120 }
116121 }
117122
123 /**
124 * Where somebody was last seen: a hollow ring and the way they were facing,
125 * dimming away. Hollow because a filled dot on this map means a body, and
126 * this is a memory of one.
127 */
128 function drawMark(ctx, m, s) {
129 const x = m.x * s;
130 const y = m.y * s;
131 const r = Math.max(2.5, 16 * s);
132 ctx.save();
133 ctx.globalAlpha = 0.8 * m.fade;
134 ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b";
135 ctx.lineWidth = Math.max(1, 2 * s);
136 disc(ctx, x, y, r, false, true);
137 ctx.beginPath();
138 ctx.moveTo(x, y);
139 ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7);
140 ctx.stroke();
141 ctx.restore();
142 }
143
118144 function disc(ctx, x, y, r, fill, stroke) {
119145 ctx.beginPath();
120146 ctx.arc(x, y, r, 0, 7);
static/val.js +27 −3
@@ -17,6 +17,7 @@ import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.
1717 import { BODY_R, moveSpeed } from "../src/glide.ts";
1818 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1919 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
20 import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js";
2021 import { choosePrompt, REACH } from "./prompt.js";
2122 import { renderSize, startLevel, WIDTHS } from "./resolution.js";
2223 import { roster, rosterKey } from "./roster.js";
@@ -50,6 +51,13 @@ let agents = null;
5051 // drawing, a snapshot interval behind the present.
5152 const timeline = makeTimeline();
5253 let drawAt = 0;
54 // Who is in sight and who just left it — see presence.js. A body the server
55 // has only now started sending has no history at all, and drawing it solid
56 // at its exact position is what makes enemies appear to teleport into a room.
57 const presence = makePresence();
58 const slid = { x: 0, y: 0, angle: 0 };
59 let marks = [];
60 let nowSec = performance.now() / 1000;
5361 const keys = new Set();
5462 // Where this person is looking: the browser's between snapshots, and the
5563 // server's word taken only when it is saying something we did not. See
@@ -104,6 +112,10 @@ function connect() {
104112 // Timed samples for every body in it, ours included: the drawing walks
105113 // between them rather than chasing the newest one.
106114 arrive(timeline, snapAt / 1000);
115 // Between rounds everybody is teleported to a spawn, so no sighting
116 // before that says anything about where anybody is now.
117 if (data.phase !== "live") forget(presence);
118 else sighted(presence, data.players, snapAt / 1000);
107119 feed(timeline, data.me.id, data.me, snapAt / 1000);
108120 for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000);
109121 // The server's angle is taken only when it is a correction, never as the
@@ -330,7 +342,15 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
330342 * You are not: your own body is the one this browser can run forward
331343 * honestly, and drawing it late is what dragged the camera backwards.
332344 */
333 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(timeline, p, drawAt));
345 const look = (p) => {
346 if (predicting() && p.id === snap.me.id) return drawnMe;
347 // A body still arriving is drawn part of the way in from where it came,
348 // rather than at the position it had already reached when we first saw it.
349 return slide(presence, p.id, bodyAt(timeline, p, drawAt), nowSec, slid);
350 };
351
352 /** How far into sight somebody is: 0 as they appear, 1 once they are solid. */
353 const shownAt = (p) => shown(presence, p.id, nowSec);
334354
335355 /**
336356 * Your own movement is only guessed at while the round is live and you are
@@ -376,6 +396,8 @@ function frame(now) {
376396 const began = performance.now();
377397 const dt = Math.min(0.1, (now - last) / 1000);
378398 last = now;
399 nowSec = began / 1000;
400 marks = ghosts(presence, nowSec);
379401 drawAt = renderTime(timeline, now / 1000);
380402 if (!map) return;
381403 gunTick(gun, dt);
@@ -402,12 +424,14 @@ function frame(now) {
402424 width: render.width,
403425 height: render.height,
404426 look,
427 shown: shownAt,
428 marks,
405429 });
406430 ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height);
407431 drawGun(ctx, gun, view.width, view.height, snap);
408432 drawMinimap();
409433 } else {
410 drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height });
434 drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height }, { marks });
411435 }
412436
413437 if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
@@ -460,7 +484,7 @@ function drawMinimap() {
460484 ctx.globalAlpha = 0.85;
461485 ctx.fillStyle = "#0b0d11";
462486 ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4);
463 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false });
487 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false, marks });
464488 ctx.restore();
465489 ctx.strokeStyle = "rgba(255,255,255,0.18)";
466490 ctx.lineWidth = 1;
↑↓ navigate ↵ open esc close

Keyboard shortcuts