quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Who came into sight, and where the last one went
The server sends only what you can see, and that stays. But it means a body stepping into a sightline arrives with no history, and the interpolation makes it worse — a body that was out of sight starts fresh at its exact position, a render delay further along than where it entered view. They do not walk into the room, they materialise mid-stride. presence.js is the arithmetic for fixing that in the drawing: how far into sight a body is (a tenth of a second, faded and slightly small), where its arrival should begin (a stride back down the line it came in on, when it was seen near there before), and where somebody was last seen alive (a mark that dims away over a second). Nothing is sent, nothing is drawn that the server did not already show you, and a corpse leaves no mark. Nothing draws it yet — that is the next commit. Co-Authored-By: Claude Opus 5 <[email protected]>
2 files
changed +300
−0
src/presence.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * Coming into sight and going out of it, as arithmetic. The case that | |
| 3 | * matters is a bot crossing a doorway: it should arrive over a tenth of a | |
| 4 | * second from the side it came from, and leave a mark where it went. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { | |
| 9 | ENTRY, | |
| 10 | FADE, | |
| 11 | forget, | |
| 12 | GHOST, | |
| 13 | ghosts, | |
| 14 | makePresence, | |
| 15 | REACH, | |
| 16 | shown, | |
| 17 | sighted, | |
| 18 | slide, | |
| 19 | SMALL, | |
| 20 | swell, | |
| 21 | } from "../static/presence.js"; | |
| 22 | ||
| 23 | const body = (over: Record<string, unknown> = {}) => ({ | |
| 24 | id: "them", | |
| 25 | x: 400, | |
| 26 | y: 200, | |
| 27 | angle: 0, | |
| 28 | alive: true, | |
| 29 | mate: false, | |
| 30 | agent: "sable", | |
| 31 | ...over, | |
| 32 | }); | |
| 33 | ||
| 34 | const here = (x: number, y: number) => ({ x, y, angle: 0 }); | |
| 35 | ||
| 36 | test("a body just in sight is drawn in over a tenth of a second", () => { | |
| 37 | const p = makePresence(); | |
| 38 | sighted(p, [body()], 10); | |
| 39 | expect(shown(p, "them", 10)).toBe(0); | |
| 40 | expect(shown(p, "them", 10 + FADE / 2)).toBeCloseTo(0.5, 5); | |
| 41 | expect(shown(p, "them", 10 + FADE * 1.01)).toBe(1); | |
| 42 | expect(shown(p, "them", 30)).toBe(1); | |
| 43 | // It starts small and reaches its own size, never past it. | |
| 44 | expect(swell(0)).toBe(SMALL); | |
| 45 | expect(swell(1)).toBe(1); | |
| 46 | expect(swell(4)).toBe(1); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a body already in sight is not faded in again by the next snapshot", () => { | |
| 50 | const p = makePresence(); | |
| 51 | sighted(p, [body()], 10); | |
| 52 | sighted(p, [body({ x: 420 })], 10.03); | |
| 53 | sighted(p, [body({ x: 440 })], 10.06); | |
| 54 | expect(shown(p, "them", 10.06)).toBeCloseTo(0.5, 5); | |
| 55 | expect(shown(p, "them", 10.2)).toBe(1); | |
| 56 | }); | |
| 57 | ||
| 58 | test("somebody nobody has ever seen is solid, not invisible", () => { | |
| 59 | const p = makePresence(); | |
| 60 | expect(shown(p, "nobody", 10)).toBe(1); | |
| 61 | expect(slide(p, "nobody", here(1, 2), 10, here(0, 0))).toEqual(here(1, 2)); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a body that steps back into view slides in from the way it came", () => { | |
| 65 | const p = makePresence(); | |
| 66 | sighted(p, [body({ x: 400, y: 200 })], 10); // seen at the door | |
| 67 | sighted(p, [], 10.1); // and gone behind the wall | |
| 68 | sighted(p, [body({ x: 500, y: 200 })], 11); // back, further along | |
| 69 | ||
| 70 | const out = here(0, 0); | |
| 71 | // It begins a stride back down the line it walked, not at its destination. | |
| 72 | const start = slide(p, "them", here(500, 200), 11, out); | |
| 73 | expect(start.x).toBeCloseTo(500 - ENTRY, 5); | |
| 74 | expect(start.y).toBeCloseTo(200, 5); | |
| 75 | // Half way through the fade it is half way in. | |
| 76 | const half = slide(p, "them", here(500, 200), 11 + FADE / 2, out); | |
| 77 | expect(half.x).toBeCloseTo(500 - ENTRY / 2, 5); | |
| 78 | // And once it is in, the drawing is the interpolation's, untouched. | |
| 79 | const done = slide(p, "them", here(500, 200), 11 + FADE * 1.01, out); | |
| 80 | expect(done).toEqual(here(500, 200)); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a body that turns up across the map does not slide across it", () => { | |
| 84 | const p = makePresence(); | |
| 85 | sighted(p, [body({ x: 100, y: 100 })], 10); | |
| 86 | sighted(p, [], 10.1); | |
| 87 | sighted(p, [body({ x: 100 + REACH + 10, y: 100 })], 12); | |
| 88 | const out = here(0, 0); | |
| 89 | const at = here(100 + REACH + 10, 100); | |
| 90 | expect(slide(p, "them", at, 12, out)).toEqual(at); | |
| 91 | // It still fades in: an arrival, just not a walk from anywhere. | |
| 92 | expect(shown(p, "them", 12)).toBe(0); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a body that has not moved since has no direction to come from", () => { | |
| 96 | const p = makePresence(); | |
| 97 | sighted(p, [body({ x: 300, y: 300 })], 10); | |
| 98 | sighted(p, [], 10.1); | |
| 99 | sighted(p, [body({ x: 300.5, y: 300 })], 11); | |
| 100 | const at = here(300.5, 300); | |
| 101 | expect(slide(p, "them", at, 11, here(0, 0))).toEqual(at); | |
| 102 | }); | |
| 103 | ||
| 104 | test("where somebody was last seen is marked, and the mark dims away", () => { | |
| 105 | const p = makePresence(); | |
| 106 | sighted(p, [body({ x: 640, y: 480, agent: "veil" })], 10); | |
| 107 | expect(ghosts(p, 10)).toEqual([]); | |
| 108 | ||
| 109 | sighted(p, [], 10.5); | |
| 110 | const fresh = ghosts(p, 10.5); | |
| 111 | expect(fresh.length).toBe(1); | |
| 112 | expect(fresh[0]).toMatchObject({ id: "them", x: 640, y: 480, agent: "veil", fade: 1 }); | |
| 113 | expect(ghosts(p, 10.5 + GHOST / 2)[0]!.fade).toBeCloseTo(0.5, 5); | |
| 114 | expect(ghosts(p, 10.5 + GHOST)).toEqual([]); | |
| 115 | // And it is forgotten, not kept for ever. | |
| 116 | expect(p.last.size).toBe(0); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a body last seen dead leaves no mark: a corpse is not a question", () => { | |
| 120 | const p = makePresence(); | |
| 121 | sighted(p, [body({ alive: false })], 10); | |
| 122 | sighted(p, [], 10.2); | |
| 123 | expect(ghosts(p, 10.2)).toEqual([]); | |
| 124 | }); | |
| 125 | ||
| 126 | test("coming back into sight takes the mark away at once", () => { | |
| 127 | const p = makePresence(); | |
| 128 | sighted(p, [body({ x: 400 })], 10); | |
| 129 | sighted(p, [], 10.2); | |
| 130 | expect(ghosts(p, 10.3).length).toBe(1); | |
| 131 | sighted(p, [body({ x: 430 })], 10.4); | |
| 132 | expect(ghosts(p, 10.4)).toEqual([]); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a body in sight the whole time is never marked and never re-faded", () => { | |
| 136 | const p = makePresence(); | |
| 137 | for (let i = 0; i < 20; i++) sighted(p, [body({ x: 400 + i * 5 })], 10 + i * 0.033); | |
| 138 | expect(ghosts(p, 10.7)).toEqual([]); | |
| 139 | expect(shown(p, "them", 10.7)).toBe(1); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a new round forgets everybody", () => { | |
| 143 | const p = makePresence(); | |
| 144 | sighted(p, [body()], 10); | |
| 145 | sighted(p, [], 10.2); | |
| 146 | forget(p); | |
| 147 | expect(ghosts(p, 10.3)).toEqual([]); | |
| 148 | expect(p.live.size).toBe(0); | |
| 149 | // And the next sighting is a first sighting, with nothing to slide from. | |
| 150 | sighted(p, [body({ x: 900 })], 11); | |
| 151 | expect(shown(p, "them", 11)).toBe(0); | |
| 152 | const at = here(900, 200); | |
| 153 | expect(slide(p, "them", at, 11, here(0, 0))).toEqual(at); | |
| 154 | }); | |
| 155 | ||
| 156 | test("two bodies are tracked apart", () => { | |
| 157 | const p = makePresence(); | |
| 158 | sighted(p, [body({ id: "a" }), body({ id: "b", x: 800 })], 10); | |
| 159 | sighted(p, [body({ id: "a" })], 10.2); | |
| 160 | expect(shown(p, "a", 10.2)).toBe(1); | |
| 161 | const marks = ghosts(p, 10.2); | |
| 162 | expect(marks.map((m) => m.id)).toEqual(["b"]); | |
| 163 | }); |
static/presence.js added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * Who came into sight, and where the last one went. | |
| 3 | * | |
| 4 | * The server only sends bodies you can actually see, and that rule is not | |
| 5 | * negotiable — but it means a body stepping into a sightline arrives with no | |
| 6 | * history at all, and the interpolation makes it worse: a body that was out | |
| 7 | * of sight starts fresh at its exact position, a render delay further along | |
| 8 | * than where it entered view. So enemies do not walk into the room, they | |
| 9 | * materialise mid-stride, and it reads as a broken game rather than a fast | |
| 10 | * one. | |
| 11 | * | |
| 12 | * This is the fix, and it is entirely in the drawing: | |
| 13 | * | |
| 14 | * - A body that has just appeared is drawn faded and slightly small for a | |
| 15 | * tenth of a second, so the eye reads an arrival rather than a pop. | |
| 16 | * - If it was seen before and not far from where it is now, the fade starts | |
| 17 | * a stride back along the line it came in on, so it slides into view from | |
| 18 | * the edge instead of appearing at its destination. | |
| 19 | * - Where somebody was last seen alive leaves a mark that dims away over a | |
| 20 | * second. It answers "where did they go", and it is information you had | |
| 21 | * already: nothing here is ever sent anywhere, and nothing is drawn that | |
| 22 | * the server did not already show you. | |
| 23 | * | |
| 24 | * Plain data in and out. Time is seconds, positions are world units. | |
| 25 | */ | |
| 26 | ||
| 27 | /** Seconds from first sight to a solid body. */ | |
| 28 | export const FADE = 0.12; | |
| 29 | /** How small a body starts, as a fraction of itself. */ | |
| 30 | export const SMALL = 0.82; | |
| 31 | /** Seconds a last-seen mark lingers. */ | |
| 32 | export const GHOST = 1; | |
| 33 | /** How far back along its path a body slides in from, in world units. */ | |
| 34 | export const ENTRY = 42; | |
| 35 | /** Last seen further away than this and it is a new sighting, not a return. */ | |
| 36 | export const REACH = 320; | |
| 37 | ||
| 38 | const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n); | |
| 39 | ||
| 40 | export const makePresence = () => ({ live: new Map(), last: new Map(), seq: 0 }); | |
| 41 | ||
| 42 | /** | |
| 43 | * A snapshot landed at `at`, carrying exactly these bodies. Anybody in it is | |
| 44 | * in sight; anybody who was and is not has just gone out of it. | |
| 45 | */ | |
| 46 | export function sighted(presence, bodies, at) { | |
| 47 | const seq = ++presence.seq; | |
| 48 | for (const body of bodies) { | |
| 49 | const have = presence.live.get(body.id); | |
| 50 | if (have) { | |
| 51 | remember(have, body, seq); | |
| 52 | continue; | |
| 53 | } | |
| 54 | const before = presence.last.get(body.id); | |
| 55 | const fresh = { since: at, seq, from: before ? entryPoint(before, body) : null }; | |
| 56 | remember(fresh, body, seq); | |
| 57 | presence.live.set(body.id, fresh); | |
| 58 | presence.last.delete(body.id); | |
| 59 | } | |
| 60 | for (const [id, it] of presence.live) { | |
| 61 | if (it.seq === seq) continue; | |
| 62 | presence.live.delete(id); | |
| 63 | // A body last seen dead is a corpse where you left it, not a question | |
| 64 | // about where somebody went: no mark. | |
| 65 | if (it.alive) presence.last.set(id, { ...it, gone: at }); | |
| 66 | else presence.last.delete(id); | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | function remember(it, body, seq) { | |
| 71 | it.seq = seq; | |
| 72 | it.x = body.x; | |
| 73 | it.y = body.y; | |
| 74 | it.angle = body.angle; | |
| 75 | it.alive = body.alive; | |
| 76 | it.mate = body.mate; | |
| 77 | it.agent = body.agent; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Where a body's arrival begins: a stride back down the line from where it | |
| 82 | * was last seen. Somebody who turns up across the map is not the same walk, | |
| 83 | * and somebody who has not moved has no direction to come from, so neither | |
| 84 | * slides. | |
| 85 | */ | |
| 86 | function entryPoint(before, body) { | |
| 87 | const dx = body.x - before.x; | |
| 88 | const dy = body.y - before.y; | |
| 89 | const span = Math.hypot(dx, dy); | |
| 90 | if (span < 2 || span > REACH) return null; | |
| 91 | const back = Math.min(ENTRY, span) / span; | |
| 92 | return { x: body.x - dx * back, y: body.y - dy * back }; | |
| 93 | } | |
| 94 | ||
| 95 | /** How far in a body is: 0 the moment it appears, 1 once it is solid. */ | |
| 96 | export function shown(presence, id, now) { | |
| 97 | const it = presence.live.get(id); | |
| 98 | return it ? clamp01((now - it.since) / FADE) : 1; | |
| 99 | } | |
| 100 | ||
| 101 | /** The size a body is drawn at while it arrives: small, then itself. */ | |
| 102 | export const swell = (t) => SMALL + (1 - SMALL) * clamp01(t); | |
| 103 | ||
| 104 | /** | |
| 105 | * The position to draw a body at: the interpolated one, or a point on the | |
| 106 | * way there from where it came in, while it is still arriving. | |
| 107 | */ | |
| 108 | export function slide(presence, id, at, now, out) { | |
| 109 | const it = presence.live.get(id); | |
| 110 | if (!it || !it.from) return at; | |
| 111 | const t = clamp01((now - it.since) / FADE); | |
| 112 | if (t >= 1) return at; | |
| 113 | out.x = it.from.x + (at.x - it.from.x) * t; | |
| 114 | out.y = it.from.y + (at.y - it.from.y) * t; | |
| 115 | out.angle = at.angle; | |
| 116 | return out; | |
| 117 | } | |
| 118 | ||
| 119 | /** The last-seen marks, freshest first in nothing in particular: ≤ a team. */ | |
| 120 | export function ghosts(presence, now) { | |
| 121 | const out = []; | |
| 122 | for (const [id, it] of presence.last) { | |
| 123 | const left = 1 - (now - it.gone) / GHOST; | |
| 124 | if (left <= 0) { | |
| 125 | presence.last.delete(id); | |
| 126 | continue; | |
| 127 | } | |
| 128 | out.push({ id, x: it.x, y: it.y, angle: it.angle, agent: it.agent, mate: it.mate, fade: left }); | |
| 129 | } | |
| 130 | return out; | |
| 131 | } | |
| 132 | ||
| 133 | /** A new round: nobody has been seen, and nobody is anywhere in particular. */ | |
| 134 | export function forget(presence) { | |
| 135 | presence.live.clear(); | |
| 136 | presence.last.clear(); | |
| 137 | } |