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]>
Wren committed 51e1b3d Browse files
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 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts