rook/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]>
4 files
changed +168
−9
src/scene.test.ts +80 −2
| @@ -6,11 +6,12 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { drawGun, makeGun } from "../static/gun.js"; |
| 9 | import { GHOST, makePresence, shown, sighted } from "../static/presence.js"; | |
| 9 | 10 | import { drawScene, makeScene } from "../static/scene.js"; |
| 10 | 11 | import { drawTopdown } from "../static/topdown.js"; |
| 11 | 12 | import { gridRows, TILE } from "./map.ts"; |
| 12 | 13 | |
| 13 | type Op = { op: string; args: unknown[] }; | |
| 14 | type Op = { op: string; args: unknown[]; alpha: number }; | |
| 14 | 15 | |
| 15 | 16 | /** A canvas that records. Every call the renderer makes has to exist here. */ |
| 16 | 17 | function fakeContext() { |
| @@ -18,7 +19,9 @@ function fakeContext() { | ||
| 18 | 19 | const note = |
| 19 | 20 | (op: string) => |
| 20 | 21 | (...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 }); | |
| 22 | 25 | }; |
| 23 | 26 | const ctx = { |
| 24 | 27 | ops, |
| @@ -244,3 +247,78 @@ test("the gun and the crosshair draw, and the hit marker only when there is one" | ||
| 244 | 247 | drawGun(loud as never, gun, 900, 570, snapshot([])); |
| 245 | 248 | expect(loud.ops.length).toBeGreaterThan(quiet); |
| 246 | 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 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 @@ | ||
| 15 | 15 | * owns. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { swell } from "./presence.js"; | |
| 18 | 19 | import { makeColumns, SKY, wallRuns } from "./slices.js"; |
| 19 | 20 | import { |
| 20 | 21 | castRay, |
| @@ -196,8 +197,14 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col | ||
| 196 | 197 | for (const player of snap.players) { |
| 197 | 198 | if (player.id === snap.me.id) continue; |
| 198 | 199 | 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 }); | |
| 200 | 204 | } |
| 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 }); | |
| 201 | 208 | |
| 202 | 209 | const p = scene.proj; |
| 203 | 210 | for (const thing of things) { |
| @@ -213,9 +220,11 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col | ||
| 213 | 220 | if (thing.behind) continue; |
| 214 | 221 | const floorY = horizon + (EYE * proj) / thing.perp; |
| 215 | 222 | 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; | |
| 217 | 225 | if (ctx.globalAlpha < 0.05) continue; |
| 218 | 226 | if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); |
| 227 | else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns); | |
| 219 | 228 | else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); |
| 220 | 229 | else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); |
| 221 | 230 | else drawZone(ctx, scene, thing, floorY, proj, step, columns); |
| @@ -257,7 +266,9 @@ function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | ||
| 257 | 266 | |
| 258 | 267 | function drawBody(ctx, scene, thing, floorY, step, columns) { |
| 259 | 268 | 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); | |
| 261 | 272 | const w = h * 0.46; |
| 262 | 273 | const cx = thing.column; |
| 263 | 274 | const top = floorY - h; |
| @@ -316,6 +327,26 @@ function drawBody(ctx, scene, thing, floorY, step, columns) { | ||
| 316 | 327 | }); |
| 317 | 328 | } |
| 318 | 329 | |
| 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 | ||
| 319 | 350 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read |
| 320 | 351 | * red and blue the same way. */ |
| 321 | 352 | function chevron(ctx, cx, top, h) { |
static/topdown.js +27 −1
| @@ -20,7 +20,9 @@ const TILE_COLOUR = { | ||
| 20 | 20 | /** |
| 21 | 21 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the |
| 22 | 22 | * 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. | |
| 24 | 26 | */ |
| 25 | 27 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { |
| 26 | 28 | const cols = map.rows[0].length; |
| @@ -79,6 +81,9 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | ||
| 79 | 81 | ctx.stroke(); |
| 80 | 82 | } |
| 81 | 83 | |
| 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); | |
| 82 | 87 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); |
| 83 | 88 | // Smoke last: on this view it hides what is under it, as it should. |
| 84 | 89 | 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) { | ||
| 115 | 120 | } |
| 116 | 121 | } |
| 117 | 122 | |
| 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 | ||
| 118 | 144 | function disc(ctx, x, y, r, fill, stroke) { |
| 119 | 145 | ctx.beginPath(); |
| 120 | 146 | 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. | ||
| 17 | 17 | import { BODY_R, moveSpeed } from "../src/glide.ts"; |
| 18 | 18 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 19 | 19 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| 20 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js"; | |
| 20 | 21 | import { choosePrompt, REACH } from "./prompt.js"; |
| 21 | 22 | import { renderSize, startLevel, WIDTHS } from "./resolution.js"; |
| 22 | 23 | import { roster, rosterKey } from "./roster.js"; |
| @@ -50,6 +51,13 @@ let agents = null; | ||
| 50 | 51 | // drawing, a snapshot interval behind the present. |
| 51 | 52 | const timeline = makeTimeline(); |
| 52 | 53 | 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; | |
| 53 | 61 | const keys = new Set(); |
| 54 | 62 | // Where this person is looking: the browser's between snapshots, and the |
| 55 | 63 | // server's word taken only when it is saying something we did not. See |
| @@ -104,6 +112,10 @@ function connect() { | ||
| 104 | 112 | // Timed samples for every body in it, ours included: the drawing walks |
| 105 | 113 | // between them rather than chasing the newest one. |
| 106 | 114 | 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); | |
| 107 | 119 | feed(timeline, data.me.id, data.me, snapAt / 1000); |
| 108 | 120 | for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000); |
| 109 | 121 | // 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() }); | ||
| 330 | 342 | * You are not: your own body is the one this browser can run forward |
| 331 | 343 | * honestly, and drawing it late is what dragged the camera backwards. |
| 332 | 344 | */ |
| 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); | |
| 334 | 354 | |
| 335 | 355 | /** |
| 336 | 356 | * Your own movement is only guessed at while the round is live and you are |
| @@ -376,6 +396,8 @@ function frame(now) { | ||
| 376 | 396 | const began = performance.now(); |
| 377 | 397 | const dt = Math.min(0.1, (now - last) / 1000); |
| 378 | 398 | last = now; |
| 399 | nowSec = began / 1000; | |
| 400 | marks = ghosts(presence, nowSec); | |
| 379 | 401 | drawAt = renderTime(timeline, now / 1000); |
| 380 | 402 | if (!map) return; |
| 381 | 403 | gunTick(gun, dt); |
| @@ -402,12 +424,14 @@ function frame(now) { | ||
| 402 | 424 | width: render.width, |
| 403 | 425 | height: render.height, |
| 404 | 426 | look, |
| 427 | shown: shownAt, | |
| 428 | marks, | |
| 405 | 429 | }); |
| 406 | 430 | ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height); |
| 407 | 431 | drawGun(ctx, gun, view.width, view.height, snap); |
| 408 | 432 | drawMinimap(); |
| 409 | 433 | } 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 }); | |
| 411 | 435 | } |
| 412 | 436 | |
| 413 | 437 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| @@ -460,7 +484,7 @@ function drawMinimap() { | ||
| 460 | 484 | ctx.globalAlpha = 0.85; |
| 461 | 485 | ctx.fillStyle = "#0b0d11"; |
| 462 | 486 | 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 }); | |
| 464 | 488 | ctx.restore(); |
| 465 | 489 | ctx.strokeStyle = "rgba(255,255,255,0.18)"; |
| 466 | 490 | ctx.lineWidth = 1; |