fix: a chapter's first mile-marker post no longer arrives late
This commit is contained in:
@@ -108,5 +108,5 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
|||||||
- **Rules kept:** the road never runs backwards; scrolling back re-arms entries and their posts come up the road again. Predictions only reach the next entry either way and wait 0.3 s after a chapter change and 0.6 s after a sweep, so a navigator jump hands nothing out mid-flight; no glint while sweeping, at most one every 0.6 s. Chapter changes are linear 0.35/0.25 s screen-door ramps (no pop; Profile's left-shoulder posts fade as Experience's right-shoulder posts arrive). Plates stay under the bloom threshold; only the lamp blooms when close. Low tier/touch: no beam, pools, pulse or glint. Reduced motion and SSR untouched.
|
- **Rules kept:** the road never runs backwards; scrolling back re-arms entries and their posts come up the road again. Predictions only reach the next entry either way and wait 0.3 s after a chapter change and 0.6 s after a sweep, so a navigator jump hands nothing out mid-flight; no glint while sweeping, at most one every 0.6 s. Chapter changes are linear 0.35/0.25 s screen-door ramps (no pop; Profile's left-shoulder posts fade as Experience's right-shoulder posts arrive). Plates stay under the bloom threshold; only the lamp blooms when close. Low tier/touch: no beam, pools, pulse or glint. Reduced motion and SSR untouched.
|
||||||
- **Budgets:** initial JS 115.54 kB gz by Vite (+13 B gz: three more export bindings from route-log for the scene), scene chunk 225.05 kB gz (+2.1). 932 tests (79 files), violations [].
|
- **Budgets:** initial JS 115.54 kB gz by Vite (+13 B gz: three more export bindings from route-log for the scene), scene chunk 225.05 kB gz (+2.1). 932 tests (79 files), violations [].
|
||||||
- **Review (Sonnet, hardware GL):** per-frame allocations removed, non-finite distance guarded; featured plates now hang on a 4.2 m arm over the road (clears traffic at ~6.3 m) so the Experience plate is fully in frame at 1440.
|
- **Review (Sonnet, hardware GL):** per-frame allocations removed, non-finite distance guarded; featured plates now hang on a 4.2 m arm over the road (clears traffic at ~6.3 m) so the Experience plate is fully in frame at 1440.
|
||||||
- **Left:** the first entry of a chapter can arrive late when the reader starts scrolling right at the heading (transit from the haze is ~6-10 s at cruise).
|
- **Fixed after (opening entry):** a chapter's first entry used to arrive up to two entries late when the reader started at the heading, because only posts in the haze (~150 m out) could take an entry. Now, for 0.75 s after a chapter change, the posts already on the road stay dark; once the reading pace is known, the one that will pass closest to the first entry's activation takes it (the nearest when the reader is still), unless a post from the haze would be on time anyway. Haze assignments wait for that, and there is no glint in the 0.3 s after a change or on a post leaving its chapter. Unit tests show entry 0 now passes within 0.4 of an entry's share of its line (it used to miss by up to about 2 shares), and within 6.5 s when the reader stays at the heading.
|
||||||
- Screenshots: docs/overnight/polish/posts-*.webp (before/after, idle contact strips before and after, feature plate zoom).
|
- Screenshots: docs/overnight/polish/posts-*.webp (before/after, idle contact strips before and after, feature plate zoom).
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export const MILESTONE_NEAR_Z = WORLD_NEAR_Z;
|
|||||||
export const MILESTONE_FAR_Z = MILESTONE_NEAR_Z - MILESTONE_SPACING * MILESTONE_SLOTS;
|
export const MILESTONE_FAR_Z = MILESTONE_NEAR_Z - MILESTONE_SPACING * MILESTONE_SLOTS;
|
||||||
/** Only a post this far up the road (still in the haze) can take a log entry. */
|
/** Only a post this far up the road (still in the haze) can take a log entry. */
|
||||||
export const MILESTONE_ASSIGN_Z = -165;
|
export const MILESTONE_ASSIGN_Z = -165;
|
||||||
|
/**
|
||||||
|
* Just after a chapter change the posts already on the road are still dark,
|
||||||
|
* so the nearer ones (up to here) may take the chapter's first entry too:
|
||||||
|
* it no longer has to wait for a post to come all the way from the haze.
|
||||||
|
*/
|
||||||
|
export const MILESTONE_LATE_ASSIGN_Z = -45;
|
||||||
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */
|
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */
|
||||||
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
|
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
|
||||||
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
|
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
|
||||||
@@ -85,6 +91,10 @@ const STILL_SECONDS = 0.4;
|
|||||||
* scroll rate is not a reading pace yet. */
|
* scroll rate is not a reading pace yet. */
|
||||||
const SETTLE_CHANGE_SECONDS = 0.3;
|
const SETTLE_CHANGE_SECONDS = 0.3;
|
||||||
const SETTLE_SWEEP_SECONDS = 0.6;
|
const SETTLE_SWEEP_SECONDS = 0.6;
|
||||||
|
/** After a chapter change, dark posts within reach wait this long for an entry before fading in. */
|
||||||
|
const HOLD_SECONDS = SETTLE_CHANGE_SECONDS + STILL_SECONDS + 0.05;
|
||||||
|
/** The opening entry is placed once the reading pace is known (hold left, seconds). */
|
||||||
|
const OPENING_AT = 0.1;
|
||||||
const PULSE_GAIN = 0.08;
|
const PULSE_GAIN = 0.08;
|
||||||
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
|
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
|
||||||
/** Atlas: 2 x 4 feature cells (512 x 232) over a strip of ten digits. */
|
/** Atlas: 2 x 4 feature cells (512 x 232) over a strip of ten digits. */
|
||||||
@@ -170,6 +180,8 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
let drift = 0;
|
let drift = 0;
|
||||||
let still = 0;
|
let still = 0;
|
||||||
let settle = 0;
|
let settle = 0;
|
||||||
|
let hold = 0;
|
||||||
|
let opening = false;
|
||||||
let sinceGlint = Number.POSITIVE_INFINITY;
|
let sinceGlint = Number.POSITIVE_INFINITY;
|
||||||
// Entries carried by posts on the road, per frame (refilled, never reallocated).
|
// Entries carried by posts on the road, per frame (refilled, never reallocated).
|
||||||
const inFlight = new Set<number>();
|
const inFlight = new Set<number>();
|
||||||
@@ -179,7 +191,8 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const due = (slot: Slot, section: MilestoneSection, p: number): number => {
|
const due = (slot: Slot, section: MilestoneSection, p: number): number => {
|
||||||
if (settle > 0) return -1;
|
// Not while a chapter has just begun: its opening entry is placed first (open_).
|
||||||
|
if (settle > 0 || (opening && hold > 0)) return -1;
|
||||||
const count = MILESTONE_COUNTS[section];
|
const count = MILESTONE_COUNTS[section];
|
||||||
const share = 1 / count;
|
const share = 1 / count;
|
||||||
const open = (i: number): boolean => armed.has(i) && !inFlight.has(i);
|
const open = (i: number): boolean => armed.has(i) && !inFlight.has(i);
|
||||||
@@ -200,6 +213,43 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
return still >= STILL_SECONDS && open(here) ? here : -1;
|
return still >= STILL_SECONDS && open(here) ? here : -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chapter has just begun: the opening entry need not wait for a post to
|
||||||
|
* come all the way from the haze. Of the posts still dark on the road, the
|
||||||
|
* one that would pass closest to the entry's activation takes it (nearest
|
||||||
|
* first on a tie, as when the reader is still at the heading).
|
||||||
|
*/
|
||||||
|
const open_ = (section: MilestoneSection, p: number, distance: number): void => {
|
||||||
|
const count = MILESTONE_COUNTS[section];
|
||||||
|
const share = 1 / count;
|
||||||
|
let entry = milestoneIndex(p, count);
|
||||||
|
if (drift > IDLE_RATE && passPoint(entry, count) < p - DUE_TOLERANCE * share) entry += 1;
|
||||||
|
if (entry >= count || !armed.has(entry)) return;
|
||||||
|
for (const s of slots) if (s.section === section && s.entry === entry) return;
|
||||||
|
const point = passPoint(entry, count);
|
||||||
|
// Reading fast enough (or the road slow enough) that a post from the haze will be on time: leave it to them.
|
||||||
|
if (drift > IDLE_RATE && MILESTONE_PASS_Z - (speed * (point - p)) / drift < MILESTONE_ASSIGN_Z) return;
|
||||||
|
let best: Slot | null = null;
|
||||||
|
let miss = Number.POSITIVE_INFINITY;
|
||||||
|
let bestZ = Number.NEGATIVE_INFINITY;
|
||||||
|
for (let k = 0; k < slots.length; k += 1) {
|
||||||
|
const slot = slots[k]!;
|
||||||
|
const z = milestoneSlot(distance, k).z;
|
||||||
|
if (slot.section !== section || slot.entry >= 0 || slot.chapter >= 0.01 || z >= MILESTONE_LATE_ASSIGN_Z) continue;
|
||||||
|
const arrival = p + Math.max(0, drift) * Math.min(30, (MILESTONE_PASS_Z - z) / Math.max(speed, 1));
|
||||||
|
const off = Math.abs(arrival - point);
|
||||||
|
if (off < miss - 1e-6 || (Math.abs(off - miss) <= 1e-6 && z > bestZ)) {
|
||||||
|
best = slot;
|
||||||
|
miss = off;
|
||||||
|
bestZ = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best === null) return;
|
||||||
|
best.entry = entry;
|
||||||
|
armed.delete(entry);
|
||||||
|
inFlight.add(entry);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posts: (): readonly MilestonePost[] => posts,
|
posts: (): readonly MilestonePost[] => posts,
|
||||||
step({ section, p, distance, pulse, deltaSeconds }: MilestoneFrame): readonly MilestonePost[] {
|
step({ section, p, distance, pulse, deltaSeconds }: MilestoneFrame): readonly MilestonePost[] {
|
||||||
@@ -220,6 +270,8 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
|
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
|
||||||
settle = Math.max(changed ? SETTLE_CHANGE_SECONDS : sweeping ? SETTLE_SWEEP_SECONDS : 0, settle - deltaSeconds);
|
settle = Math.max(changed ? SETTLE_CHANGE_SECONDS : sweeping ? SETTLE_SWEEP_SECONDS : 0, settle - deltaSeconds);
|
||||||
const live = p !== null && isMilestoneSection(section) ? section : null;
|
const live = p !== null && isMilestoneSection(section) ? section : null;
|
||||||
|
hold = changed && live !== null ? HOLD_SECONDS : Math.max(0, hold - deltaSeconds);
|
||||||
|
if (changed) opening = live !== null;
|
||||||
// Every entry is armed again once the reader is well away from it.
|
// Every entry is armed again once the reader is well away from it.
|
||||||
if (live !== armedFor) {
|
if (live !== armedFor) {
|
||||||
armed.clear();
|
armed.clear();
|
||||||
@@ -236,6 +288,10 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
if (changed || sweeping) armed.add(milestoneIndex(p, count));
|
if (changed || sweeping) armed.add(milestoneIndex(p, count));
|
||||||
}
|
}
|
||||||
sinceGlint += deltaSeconds;
|
sinceGlint += deltaSeconds;
|
||||||
|
if (live !== null && p !== null && opening && hold > 0 && hold <= OPENING_AT) {
|
||||||
|
opening = false;
|
||||||
|
open_(live, p, distance);
|
||||||
|
}
|
||||||
for (let k = 0; k < slots.length; k += 1) {
|
for (let k = 0; k < slots.length; k += 1) {
|
||||||
const slot = slots[k]!;
|
const slot = slots[k]!;
|
||||||
const { z, id } = milestoneSlot(distance, k);
|
const { z, id } = milestoneSlot(distance, k);
|
||||||
@@ -245,7 +301,8 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
slot.id = id;
|
slot.id = id;
|
||||||
slot.section = live;
|
slot.section = live;
|
||||||
slot.entry = -1;
|
slot.entry = -1;
|
||||||
slot.chapter = live === null ? 0 : 1;
|
// Born in the haze it is hidden anyway; nearer (the first frame) it fades in.
|
||||||
|
slot.chapter = live === null || z > MILESTONE_ASSIGN_Z ? 0 : 1;
|
||||||
}
|
}
|
||||||
slot.z = z;
|
slot.z = z;
|
||||||
// An unclaimed or faded post that is still ahead joins the current chapter.
|
// An unclaimed or faded post that is still ahead joins the current chapter.
|
||||||
@@ -253,9 +310,6 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
slot.section = live;
|
slot.section = live;
|
||||||
slot.entry = -1;
|
slot.entry = -1;
|
||||||
}
|
}
|
||||||
const target = live !== null && slot.section === live ? 1 : 0;
|
|
||||||
const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS);
|
|
||||||
slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp);
|
|
||||||
if (live !== null && p !== null && slot.section === live && slot.entry < 0 && z < MILESTONE_ASSIGN_Z) {
|
if (live !== null && p !== null && slot.section === live && slot.entry < 0 && z < MILESTONE_ASSIGN_Z) {
|
||||||
slot.entry = due(slot, live, p);
|
slot.entry = due(slot, live, p);
|
||||||
if (slot.entry >= 0) {
|
if (slot.entry >= 0) {
|
||||||
@@ -263,9 +317,14 @@ export function createMilestoneRoad(full: boolean) {
|
|||||||
inFlight.add(slot.entry);
|
inFlight.add(slot.entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Dark posts within reach stay dark while they may still be given the first entry.
|
||||||
|
const waiting = hold > 0 && slot.entry < 0 && slot.chapter < 0.01 && z < MILESTONE_LATE_ASSIGN_Z;
|
||||||
|
const target = live !== null && slot.section === live && !waiting ? 1 : 0;
|
||||||
|
const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS);
|
||||||
|
slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp);
|
||||||
const presence = slot.section === null ? 0 : slot.chapter * milestoneRangeFade(z);
|
const presence = slot.section === null ? 0 : slot.chapter * milestoneRangeFade(z);
|
||||||
const feature = slot.entry >= 0 && presence > 0;
|
const feature = slot.entry >= 0 && presence > 0;
|
||||||
if (full && feature && crossed && !sweeping && sinceGlint >= GLINT_GAP_SECONDS) {
|
if (full && feature && crossed && !sweeping && settle <= 0 && slot.section === live && sinceGlint >= GLINT_GAP_SECONDS) {
|
||||||
glints[k] = 1;
|
glints[k] = 1;
|
||||||
sinceGlint = 0;
|
sinceGlint = 0;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
MILESTONE_ASSIGN_Z,
|
MILESTONE_ASSIGN_Z,
|
||||||
MILESTONE_FAR_Z,
|
MILESTONE_FAR_Z,
|
||||||
MILESTONE_FLASH_MAX_RATE,
|
MILESTONE_FLASH_MAX_RATE,
|
||||||
|
MILESTONE_LATE_ASSIGN_Z,
|
||||||
|
|
||||||
MILESTONE_NEAR_Z,
|
MILESTONE_NEAR_Z,
|
||||||
MILESTONE_PASS_Z,
|
MILESTONE_PASS_Z,
|
||||||
MILESTONE_SIDE,
|
MILESTONE_SIDE,
|
||||||
@@ -200,7 +202,53 @@ describe('mile markers on the road', () => {
|
|||||||
expect(seen.filter((pass) => pass.reverse).map((pass) => pass.entry)).toEqual([2, 1, 0]);
|
expect(seen.filter((pass) => pass.reverse).map((pass) => pass.entry)).toEqual([2, 1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only hands an entry to a post still far up the road, in the haze', () => {
|
it('does not keep the first entry waiting when the reader starts scrolling at the heading', () => {
|
||||||
|
for (const [section, rate, speed] of [
|
||||||
|
['profile', 0.03, 18],
|
||||||
|
['profile', 0.05, 18],
|
||||||
|
['experience', 0.03, 18],
|
||||||
|
['experience', 0.05, 25],
|
||||||
|
] as const) {
|
||||||
|
const count = MILESTONE_COUNTS[section];
|
||||||
|
for (const lead of [0, 0.4, 1.7, 2.9]) {
|
||||||
|
const road = createMilestoneRoad(true);
|
||||||
|
// Cruising through the chapter before, then straight into reading from the heading.
|
||||||
|
const state = drive(road, 3 + lead, () => ({ section: 'start', p: null, speed }));
|
||||||
|
const seen: Array<{ entry: number; p: number }> = [];
|
||||||
|
let last: readonly number[] = road.posts().map((post) => post.z);
|
||||||
|
drive(road, 1 / rate, (t) => ({ section, p: Math.min(1, t * rate), speed }), state, (posts, _t, p) => {
|
||||||
|
posts.forEach((post, k) => {
|
||||||
|
if (post.entry >= 0 && post.presence > 0 && last[k]! < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) {
|
||||||
|
seen.push({ entry: post.entry, p: p! });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
last = posts.map((post) => post.z);
|
||||||
|
});
|
||||||
|
expect(seen[0]?.entry).toBe(0);
|
||||||
|
// Within a post's spacing of the line (the road brings one every 60 m); it used to be up to two entries late.
|
||||||
|
expect(Math.abs(seen[0]!.p - passPoint(0, count))).toBeLessThan(0.4 / count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the opening entry by soon when the reader stays at the heading', () => {
|
||||||
|
for (const lead of [0, 0.4, 1.7, 2.9]) {
|
||||||
|
const road = createMilestoneRoad(true);
|
||||||
|
const state = drive(road, 3 + lead, () => ({ section: 'start', p: null, speed: 18 }));
|
||||||
|
let first = Number.POSITIVE_INFINITY;
|
||||||
|
let last: readonly number[] = road.posts().map((post) => post.z);
|
||||||
|
drive(road, 12, () => ({ section: 'profile', p: 0, speed: 18 }), state, (posts, t) => {
|
||||||
|
posts.forEach((post, k) => {
|
||||||
|
if (post.entry === 0 && post.presence > 0 && last[k]! < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) first = Math.min(first, t);
|
||||||
|
});
|
||||||
|
last = posts.map((post) => post.z);
|
||||||
|
});
|
||||||
|
// A post already on the road, not one from the haze (~8.5 s at this speed).
|
||||||
|
expect(first).toBeLessThan(6.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only hands an entry to a post still far up the road, or to a dark one as a chapter begins', () => {
|
||||||
const road = createMilestoneRoad(true);
|
const road = createMilestoneRoad(true);
|
||||||
const born = new Map<number, number>();
|
const born = new Map<number, number>();
|
||||||
drive(road, 30, (t) => ({ section: 'experience', p: Math.min(1, t * 0.03), speed: 30 }), { distance: 0 }, (posts) => {
|
drive(road, 30, (t) => ({ section: 'experience', p: Math.min(1, t * 0.03), speed: 30 }), { distance: 0 }, (posts) => {
|
||||||
@@ -209,7 +257,10 @@ describe('mile markers on the road', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(born.size).toBeGreaterThan(0);
|
expect(born.size).toBeGreaterThan(0);
|
||||||
for (const z of born.values()) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z);
|
// The opening entry may go to a nearer post that was still dark; every later one comes from the haze.
|
||||||
|
const zs = [...born.values()];
|
||||||
|
expect(zs[0]!).toBeLessThan(MILESTONE_LATE_ASSIGN_Z);
|
||||||
|
for (const z of zs.slice(1)) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user