feat(tokens): animated paths for scene and NPC tokens

Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-14 08:48:41 +08:00
parent 46bec1a86a
commit 1ab6ffd593
32 changed files with 2933 additions and 28 deletions
+7
View File
@@ -5,6 +5,7 @@ import { normalizeNpcDisposition } from './npcDisposition';
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
import { normalizeHexColor } from '../npcs/npcGroups';
import { normalizeTokenPath, type TokenPath } from './tokenPath';
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
@@ -41,6 +42,8 @@ export type SceneNpcToken = {
sizeN: number;
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
disposition: NpcDisposition;
/** Optional movement path (editor → session playback). */
path?: TokenPath | null;
};
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
@@ -207,5 +210,9 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
ny: Math.max(0, Math.min(1, ny)),
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
...(() => {
const path = normalizeTokenPath((obj as { path?: unknown }).path);
return path ? { path } : {};
})(),
};
}
+5
View File
@@ -2,6 +2,7 @@
import type { SceneTokenId, TokenId } from './ids';
import { asSceneTokenId, asTokenId } from './ids';
import { normalizeTokenPath, type TokenPath } from './tokenPath';
export type { SceneTokenId, TokenId };
export { asSceneTokenId, asTokenId };
@@ -24,6 +25,8 @@ export type SceneToken = {
sizeN: number;
/** Непрерывный угол поворота в градусах. */
rotationDeg: number;
/** Optional movement path (editor → session playback). */
path?: TokenPath | null;
};
export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
@@ -56,6 +59,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null {
const sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N);
const rotationDeg =
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
const path = normalizeTokenPath((obj as { path?: unknown }).path);
return {
id: asSceneTokenId(obj.id),
tokenId: asTokenId(obj.tokenId),
@@ -63,6 +67,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null {
ny: Math.max(0, Math.min(1, ny)),
sizeN,
rotationDeg,
...(path ? { path } : {}),
};
}
+3
View File
@@ -10,6 +10,9 @@ export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
export * from './sceneGridSnap';
export * from './tokenPath';
export * from './tokenPathSession';
export * from './tokenPathPlayback';
export * from './scenePreviewRotation';
export * from './sceneTraps';
export * from './sceneView';
+28 -9
View File
@@ -1,3 +1,5 @@
import { rotateTokenPathByCwSteps, type TokenPath } from './tokenPath';
/** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */
export type PreviewRotationDeg = 0 | 90 | 180 | 270;
@@ -43,29 +45,46 @@ function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number }>(
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number; path?: TokenPath | null }>(
items: readonly T[],
steps: number,
): T[] {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return items.map((item) => ({ ...item }));
if (n === 0) {
return items.map((item) => ({
...item,
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
}));
}
return items.map((item) => {
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
return { ...item, nx: p.nx, ny: p.ny };
const next: T = { ...item, nx: p.nx, ny: p.ny };
if (item.path) {
(next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n);
}
return next;
});
}
/** Non-player tokens: move with the map and keep facing relative to the art. */
export function rotateSceneTokensByCwSteps<T extends { nx: number; ny: number; rotationDeg: number }>(
items: readonly T[],
steps: number,
): T[] {
export function rotateSceneTokensByCwSteps<
T extends { nx: number; ny: number; rotationDeg: number; path?: TokenPath | null },
>(items: readonly T[], steps: number): T[] {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return items.map((item) => ({ ...item }));
if (n === 0) {
return items.map((item) => ({
...item,
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
}));
}
const delta = n * 90;
return items.map((item) => {
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
return { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta };
const next: T = { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta };
if (item.path) {
(next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n);
}
return next;
});
}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findNearestAheadDistance,
normalizeTokenPath,
reverseTokenPathPoints,
rotateTokenPathByCwSteps,
sampleTokenPathAtProgress,
tokenPathTotalLength,
tryAppendPathPoint,
TOKEN_PATH_MIN_POINT_DIST,
} from './tokenPath';
void test('tryAppendPathPoint rejects double-click distance', () => {
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
assert.ok(a);
const b = tryAppendPathPoint(a!, { nx: 0.1 + TOKEN_PATH_MIN_POINT_DIST / 2, ny: 0.1 });
assert.equal(b, null);
const c = tryAppendPathPoint(a!, { nx: 0.5, ny: 0.5 });
assert.equal(c?.length, 2);
});
void test('normalizeTokenPath requires ≥2 points', () => {
assert.equal(normalizeTokenPath({ points: [{ nx: 0.1, ny: 0.1 }] }), null);
const p = normalizeTokenPath({
points: [
{ nx: 0.1, ny: 0.1 },
{ nx: 0.5, ny: 0.1 },
],
durationSec: 10,
closed: true,
loopMode: 'loop',
});
assert.ok(p);
assert.equal(p!.loopMode, 'loop');
assert.equal(p!.closed, true);
});
void test('loop forced to once when not closed', () => {
const p = normalizeTokenPath({
points: [
{ nx: 0, ny: 0 },
{ nx: 1, ny: 0 },
],
closed: false,
loopMode: 'loop',
});
assert.ok(p);
assert.equal(p!.loopMode, 'once');
});
void test('sampleTokenPathAtProgress endpoints', () => {
const p = normalizeTokenPath({
points: [
{ nx: 0, ny: 0.5 },
{ nx: 1, ny: 0.5 },
],
durationSec: 5,
facingMode: 'fixed',
fixedRotationDeg: 45,
});
assert.ok(p);
const a = sampleTokenPathAtProgress(p!, 0);
const b = sampleTokenPathAtProgress(p!, 1);
assert.ok(a && b);
assert.ok(Math.abs(a!.nx - 0) < 1e-6);
assert.ok(Math.abs(b!.nx - 1) < 1e-6);
assert.equal(a!.rotationDeg, 45);
});
void test('reverse and rotate path', () => {
const pts = reverseTokenPathPoints([
{ nx: 0.1, ny: 0.2 },
{ nx: 0.8, ny: 0.2 },
]);
assert.deepEqual(pts[0], { nx: 0.8, ny: 0.2 });
const path = normalizeTokenPath({
points: [
{ nx: 0.2, ny: 0.1 },
{ nx: 0.8, ny: 0.1 },
],
fixedRotationDeg: 10,
})!;
const rotated = rotateTokenPathByCwSteps(path, 1);
assert.ok(Math.abs(rotated.points[0]!.nx - 0.9) < 1e-6);
assert.ok(Math.abs(rotated.points[0]!.ny - 0.2) < 1e-6);
assert.equal(rotated.fixedRotationDeg, 100);
assert.ok(tokenPathTotalLength(path) > 0);
});
void test('findNearestAheadDistance prefers ahead of fromDist', () => {
const path = normalizeTokenPath({
points: [
{ nx: 0, ny: 0.5 },
{ nx: 1, ny: 0.5 },
],
})!;
const mid = findNearestAheadDistance(path, 0.7, 0.5, 0.2);
assert.ok(mid > 0.2);
});
+306
View File
@@ -0,0 +1,306 @@
/** Path animation for scene tokens / NPC tokens (project-persisted). */
export type TokenPathPoint = { nx: number; ny: number };
/** How the token faces while moving. */
export type TokenPathFacingMode = 'tangentSmooth' | 'fixed';
/**
* once — play to end and stay.
* pingpong — reverse at ends forever (until stopped).
* loop — only when path is closed (explicit «Замкнуть»).
*/
export type TokenPathLoopMode = 'once' | 'pingpong' | 'loop';
/** When playback starts after the scene becomes current. */
export type TokenPathStartMode = 'onEnter' | 'delayed';
export type TokenPath = {
points: TokenPathPoint[];
/** Explicit close (RMB «Замкнуть» on a point). Enables loop mode. */
closed: boolean;
loopMode: TokenPathLoopMode;
/** Seconds to traverse the full path once (or one direction for pingpong). */
durationSec: number;
startMode: TokenPathStartMode;
/** Delay after scene enter when startMode === 'delayed'. */
delaySec: number;
facingMode: TokenPathFacingMode;
/** Used when facingMode === 'fixed'. */
fixedRotationDeg: number;
};
export const TOKEN_PATH_MIN_POINT_DIST = 0.02;
export const TOKEN_PATH_DURATION_MIN_SEC = 0.5;
export const TOKEN_PATH_DURATION_MAX_SEC = 600;
export const TOKEN_PATH_DELAY_MAX_SEC = 600;
export const DEFAULT_TOKEN_PATH_DURATION_SEC = 8;
export const DEFAULT_TOKEN_PATH_DELAY_SEC = 3;
export function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(1, v));
}
export function distNorm(a: TokenPathPoint, b: TokenPathPoint): number {
const dx = a.nx - b.nx;
const dy = a.ny - b.ny;
return Math.hypot(dx, dy);
}
export function createEmptyTokenPath(): TokenPath {
return {
points: [],
closed: false,
loopMode: 'once',
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
startMode: 'onEnter',
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
facingMode: 'tangentSmooth',
fixedRotationDeg: 0,
};
}
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
return raw === 'fixed' ? 'fixed' : 'tangentSmooth';
}
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
if (raw === 'pingpong') return 'pingpong';
if (raw === 'loop' && closed) return 'loop';
return 'once';
}
export function normalizeTokenPathStartMode(raw: unknown): TokenPathStartMode {
return raw === 'delayed' ? 'delayed' : 'onEnter';
}
export function clampTokenPathDurationSec(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DURATION_SEC;
return Math.max(TOKEN_PATH_DURATION_MIN_SEC, Math.min(TOKEN_PATH_DURATION_MAX_SEC, n));
}
export function clampTokenPathDelaySec(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DELAY_SEC;
return Math.max(0, Math.min(TOKEN_PATH_DELAY_MAX_SEC, n));
}
export function normalizeTokenPathPoint(raw: unknown): TokenPathPoint | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<TokenPathPoint>;
if (typeof obj.nx !== 'number' || !Number.isFinite(obj.nx)) return null;
if (typeof obj.ny !== 'number' || !Number.isFinite(obj.ny)) return null;
return { nx: clamp01(obj.nx), ny: clamp01(obj.ny) };
}
/** Drop points closer than TOKEN_PATH_MIN_POINT_DIST to the previous kept point. */
export function filterMinPointDistance(points: readonly TokenPathPoint[]): TokenPathPoint[] {
if (points.length === 0) return [];
const out: TokenPathPoint[] = [{ ...points[0]! }];
for (let i = 1; i < points.length; i += 1) {
const p = points[i]!;
const prev = out[out.length - 1]!;
if (distNorm(prev, p) + 1e-9 >= TOKEN_PATH_MIN_POINT_DIST) {
out.push({ ...p });
}
}
return out;
}
export function normalizeTokenPath(raw: unknown): TokenPath | null {
if (raw == null) return null;
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<TokenPath>;
const points0 = Array.isArray(obj.points)
? obj.points.map(normalizeTokenPathPoint).filter((p): p is TokenPathPoint => Boolean(p))
: [];
const points = filterMinPointDistance(points0);
if (points.length < 2) return null;
const closed = Boolean(obj.closed);
const loopMode = normalizeTokenPathLoopMode(obj.loopMode, closed);
return {
points,
closed,
loopMode: closed ? loopMode : loopMode === 'loop' ? 'once' : loopMode,
durationSec: clampTokenPathDurationSec(obj.durationSec),
startMode: normalizeTokenPathStartMode(obj.startMode),
delaySec: clampTokenPathDelaySec(obj.delaySec),
facingMode: normalizeTokenPathFacingMode(obj.facingMode),
fixedRotationDeg:
typeof obj.fixedRotationDeg === 'number' && Number.isFinite(obj.fixedRotationDeg)
? obj.fixedRotationDeg
: 0,
};
}
/** Points used for drawing/length: if closed, append first at end when not already equal. */
export function tokenPathPolyline(path: TokenPath): TokenPathPoint[] {
if (path.points.length === 0) return [];
if (!path.closed) return path.points.map((p) => ({ ...p }));
const first = path.points[0]!;
const last = path.points[path.points.length - 1]!;
if (distNorm(first, last) < 1e-6) return path.points.map((p) => ({ ...p }));
return [...path.points.map((p) => ({ ...p })), { ...first }];
}
export function tokenPathTotalLength(path: TokenPath): number {
const pts = tokenPathPolyline(path);
let len = 0;
for (let i = 1; i < pts.length; i += 1) {
len += distNorm(pts[i - 1]!, pts[i]!);
}
return len;
}
export type TokenPathSample = {
nx: number;
ny: number;
/** Degrees, CSS-style (0 = right? we use same as token rotationDeg — image up + rotate). */
rotationDeg: number;
/** Distance along polyline [0, length]. */
dist: number;
};
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function segmentAngleDeg(a: TokenPathPoint, b: TokenPathPoint): number {
// Screen y-down: atan2(dy, dx) with dy positive downward.
const deg = (Math.atan2(b.ny - a.ny, b.nx - a.nx) * 180) / Math.PI;
// Token art faces "up" (Y); rotate so forward matches travel.
return deg + 90;
}
function shortestAngleLerp(from: number, to: number, t: number): number {
let delta = ((to - from + 540) % 360) - 180;
return from + delta * t;
}
/**
* Sample position/facing at distance `dist` along the polyline (clamped).
*/
export function sampleTokenPathAtDistance(path: TokenPath, dist: number): TokenPathSample | null {
const pts = tokenPathPolyline(path);
if (pts.length < 2) return null;
const total = tokenPathTotalLength(path);
if (total <= 1e-9) {
const p = pts[0]!;
return {
nx: p.nx,
ny: p.ny,
rotationDeg: path.facingMode === 'fixed' ? path.fixedRotationDeg : 0,
dist: 0,
};
}
const d = Math.max(0, Math.min(total, dist));
let walked = 0;
for (let i = 1; i < pts.length; i += 1) {
const a = pts[i - 1]!;
const b = pts[i]!;
const seg = distNorm(a, b);
if (seg <= 1e-9) continue;
if (walked + seg >= d - 1e-9) {
const t = (d - walked) / seg;
const nx = lerp(a.nx, b.nx, t);
const ny = lerp(a.ny, b.ny, t);
let rotationDeg = path.fixedRotationDeg;
if (path.facingMode === 'tangentSmooth') {
const ang = segmentAngleDeg(a, b);
// Blend with previous segment for smoother corners.
if (i >= 2) {
const prevA = pts[i - 2]!;
const prevAng = segmentAngleDeg(prevA, a);
rotationDeg = shortestAngleLerp(prevAng, ang, Math.min(1, t + 0.35));
} else {
rotationDeg = ang;
}
}
return { nx, ny, rotationDeg, dist: d };
}
walked += seg;
}
const last = pts[pts.length - 1]!;
const prev = pts[pts.length - 2]!;
return {
nx: last.nx,
ny: last.ny,
rotationDeg:
path.facingMode === 'fixed' ? path.fixedRotationDeg : segmentAngleDeg(prev, last),
dist: total,
};
}
/**
* Progress u in [0,1] over one durationSec pass (pingpong handled by caller via triangle wave).
*/
export function sampleTokenPathAtProgress(path: TokenPath, u: number): TokenPathSample | null {
const total = tokenPathTotalLength(path);
const t = Math.max(0, Math.min(1, u));
return sampleTokenPathAtDistance(path, t * total);
}
/**
* Nearest point on the polyline that lies at or ahead of `fromDist` along the route direction.
* Returns distance along path to that sample (for resume-after-drag).
*/
export function findNearestAheadDistance(
path: TokenPath,
nx: number,
ny: number,
fromDist: number,
): number {
const pts = tokenPathPolyline(path);
const total = tokenPathTotalLength(path);
if (pts.length < 2 || total <= 1e-9) return 0;
const start = Math.max(0, Math.min(total, fromDist));
const samples = 64;
let bestDist = start;
let bestErr = Number.POSITIVE_INFINITY;
for (let i = 0; i <= samples; i += 1) {
const d = start + ((total - start) * i) / samples;
const s = sampleTokenPathAtDistance(path, d);
if (!s) continue;
const err = Math.hypot(s.nx - nx, s.ny - ny);
if (err < bestErr) {
bestErr = err;
bestDist = d;
}
}
// Also check remaining if pingpong will reverse — for once/loop, ahead is enough.
return bestDist;
}
export function reverseTokenPathPoints(points: readonly TokenPathPoint[]): TokenPathPoint[] {
return [...points].reverse().map((p) => ({ ...p }));
}
export function rotateTokenPathByCwSteps(path: TokenPath, steps: number): TokenPath {
const n = ((steps % 4) + 4) % 4;
if (n === 0) {
return {
...path,
points: path.points.map((p) => ({ ...p })),
};
}
// Import inline to avoid circular deps — callers use scenePreviewRotation.
// We duplicate the CW formula here for a self-contained module OR accept a rotator.
let points = path.points.map((p) => ({ ...p }));
for (let s = 0; s < n; s += 1) {
points = points.map((p) => ({ nx: clamp01(1 - p.ny), ny: clamp01(p.nx) }));
}
const fixedRotationDeg = path.fixedRotationDeg + n * 90;
return { ...path, points, fixedRotationDeg };
}
/** Try add a point; returns null if too close to the last point. */
export function tryAppendPathPoint(
points: readonly TokenPathPoint[],
next: TokenPathPoint,
): TokenPathPoint[] | null {
const p = { nx: clamp01(next.nx), ny: clamp01(next.ny) };
if (points.length === 0) return [p];
const last = points[points.length - 1]!;
if (distNorm(last, p) < TOKEN_PATH_MIN_POINT_DIST) return null;
return [...points, p];
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { computePathPlaybackSample, resumeFromPose } from './tokenPathPlayback';
import type { TokenPath } from './tokenPath';
import type { TokenPathPlaybackEntry } from './tokenPathSession';
function path(): TokenPath {
return {
points: [
{ nx: 0, ny: 0.5 },
{ nx: 1, ny: 0.5 },
],
closed: false,
loopMode: 'once',
durationSec: 2,
startMode: 'onEnter',
delaySec: 0,
facingMode: 'tangentSmooth',
fixedRotationDeg: 0,
};
}
function entry(partial: Partial<TokenPathPlaybackEntry>): TokenPathPlaybackEntry {
return {
kind: 'token',
placementId: 't1',
phase: 'moving',
segmentStartedAtMs: 0,
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: 2,
pathLength: 1,
...partial,
};
}
void test('once reaches end and marks done', () => {
const p = path();
const r = computePathPlaybackSample({
path: p,
entry: entry({ phase: 'moving', segmentStartedAtMs: 0 }),
nowMs: 2500,
});
assert.ok(r);
assert.equal(r!.markDone, true);
assert.equal(r!.phase, 'done');
assert.ok(Math.abs(r!.sample.nx - 1) < 1e-6);
});
void test('delay holds at start until delay elapses', () => {
const p = { ...path(), startMode: 'delayed' as const, delaySec: 1 };
const early = computePathPlaybackSample({
path: p,
entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }),
nowMs: 400,
});
assert.ok(early);
assert.equal(early!.phase, 'delay');
assert.ok(Math.abs(early!.sample.nx) < 1e-6);
const late = computePathPlaybackSample({
path: p,
entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }),
nowMs: 1500,
});
assert.ok(late);
assert.equal(late!.phase, 'moving');
assert.ok(late!.sample.nx > 0.2);
});
void test('stopped freezes at baseDist', () => {
const p = path();
const r = computePathPlaybackSample({
path: p,
entry: entry({ phase: 'stopped', baseDist: 0.25 }),
nowMs: 99999,
});
assert.ok(r);
assert.equal(r!.phase, 'stopped');
assert.ok(Math.abs(r!.sample.nx - 0.25) < 1e-6);
});
void test('pingpong reverses after end', () => {
const p = { ...path(), loopMode: 'pingpong' as const };
const r = computePathPlaybackSample({
path: p,
entry: entry({ phase: 'moving', durationSec: 2, pathLength: 1 }),
nowMs: 3000, // 1.5 path lengths → back toward start
});
assert.ok(r);
assert.equal(r!.markDone, false);
assert.ok(r!.sample.nx < 0.6);
});
void test('resumeFromPose picks ahead distance', () => {
const p = path();
const d = resumeFromPose(p, 0.4, 0.5, 0.1);
assert.ok(d >= 0.1);
assert.ok(d <= 1);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Pure playback sampling for token paths (shared by control / presentation / editor preview).
*/
import {
findNearestAheadDistance,
sampleTokenPathAtDistance,
tokenPathTotalLength,
type TokenPath,
type TokenPathSample,
} from './tokenPath';
import type { TokenPathPlaybackEntry } from './tokenPathSession';
export function computePathPlaybackSample(args: {
path: TokenPath;
entry: TokenPathPlaybackEntry;
nowMs: number;
}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null {
const { path, entry, nowMs } = args;
const length = entry.pathLength > 1e-9 ? entry.pathLength : tokenPathTotalLength(path);
if (length <= 1e-9) return null;
const durationMs = Math.max(0.5, entry.durationSec) * 1000;
const speed = length / durationMs;
if (entry.phase === 'stopped' || entry.phase === 'done') {
const sample = sampleTokenPathAtDistance(path, clampDist(entry.baseDist, length));
if (!sample) return null;
return { sample, phase: entry.phase, markDone: false };
}
let elapsed = Math.max(0, nowMs - entry.segmentStartedAtMs);
if (entry.phase === 'delay') {
const delayMs = Math.max(0, path.delaySec) * 1000;
if (elapsed < delayMs) {
const start = sampleTokenPathAtDistance(path, 0);
if (!start) return null;
return { sample: start, phase: 'delay', markDone: false };
}
elapsed -= delayMs;
}
// Moving (including post-delay).
let base = entry.baseDist;
let dir: 1 | -1 = entry.direction || 1;
let travel = speed * elapsed;
if (entry.rejoinDist != null && Number.isFinite(entry.rejoinDist)) {
const target = clampDist(entry.rejoinDist, length);
const need = Math.abs(target - base);
if (travel < need) {
const dist = base + Math.sign(target - base || 1) * travel;
const sample = sampleTokenPathAtDistance(path, clampDist(dist, length));
if (!sample) return null;
return { sample, phase: 'moving', markDone: false };
}
travel -= need;
base = target;
}
return advanceAlongPath({
path,
length,
baseDist: base,
direction: dir,
travelDist: travel,
});
}
function clampDist(d: number, length: number): number {
return Math.max(0, Math.min(length, d));
}
function advanceAlongPath(args: {
path: TokenPath;
length: number;
baseDist: number;
direction: 1 | -1;
travelDist: number;
}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null {
const { path, length, baseDist, direction, travelDist } = args;
const loopMode = path.loopMode;
const closed = path.closed;
let dist = baseDist + direction * travelDist;
let markDone = false;
if (loopMode === 'pingpong') {
const period = Math.max(length * 2, 1e-9);
let d = ((dist % period) + period) % period;
if (d > length) d = period - d;
dist = d;
} else if (loopMode === 'loop' && closed) {
dist = ((dist % length) + length) % length;
} else {
// once (or loop without closed)
if (dist >= length) {
dist = length;
markDone = true;
} else if (dist <= 0) {
dist = 0;
markDone = true;
}
}
const sample = sampleTokenPathAtDistance(path, dist);
if (!sample) return null;
return { sample, phase: markDone ? 'done' : 'moving', markDone };
}
export function resumeFromPose(path: TokenPath, nx: number, ny: number, fromDist: number): number {
return findNearestAheadDistance(path, nx, ny, fromDist);
}
+77
View File
@@ -0,0 +1,77 @@
/** Session runtime for token/NPC path playback and presentation visibility. */
export type TokenPathTargetKind = 'token' | 'npcToken';
export type TokenPathTargetRef = {
kind: TokenPathTargetKind;
placementId: string;
};
export function tokenPathKey(kind: TokenPathTargetKind, placementId: string): string {
return `${kind}:${placementId}`;
}
export function parseTokenPathKey(key: string): TokenPathTargetRef | null {
const i = key.indexOf(':');
if (i <= 0) return null;
const kind = key.slice(0, i);
const placementId = key.slice(i + 1);
if ((kind !== 'token' && kind !== 'npcToken') || !placementId) return null;
return { kind, placementId };
}
/**
* delay — waiting startMode delayed.
* moving — animating along path (or rejoining after drag).
* done — once finished at end.
* stopped — user stopped; drag allowed; can resume.
*/
export type TokenPathPlaybackPhase = 'delay' | 'moving' | 'done' | 'stopped';
export type TokenPathPlaybackEntry = {
kind: TokenPathTargetKind;
placementId: string;
phase: TokenPathPlaybackPhase;
/** Wall-clock ms when current moving/delay segment started (main Date.now). */
segmentStartedAtMs: number;
/** Path distance at segment start (for moving). */
baseDist: number;
direction: 1 | -1;
/** If set while moving: first travel to this dist (rejoin), then clear. */
rejoinDist: number | null;
/** Snapshot of durationSec from path at start. */
durationSec: number;
/** Snapshot of total polyline length at start. */
pathLength: number;
};
export type TokenPathSessionState = {
revision: number;
/** Keys visible on presentation («Показать путь»). */
presentationVisible: Record<string, true>;
playback: Record<string, TokenPathPlaybackEntry>;
/** Monotonic clock for renderers (updated on dispatch / heartbeats). */
serverNowMs: number;
};
export type TokenPathSessionEvent =
| { kind: 'clear' }
| { kind: 'showPresentation'; target: TokenPathTargetRef }
| { kind: 'hidePresentation'; target: TokenPathTargetRef }
| { kind: 'stop'; target: TokenPathTargetRef; atDist?: number }
| { kind: 'resume'; target: TokenPathTargetRef; nx: number; ny: number; fromDist: number }
| { kind: 'resetToStart'; target: TokenPathTargetRef }
| {
kind: 'seedPlayback';
entry: Omit<TokenPathPlaybackEntry, 'segmentStartedAtMs'> & { segmentStartedAtMs?: number };
}
| { kind: 'markDone'; target: TokenPathTargetRef };
export function emptyTokenPathSessionState(revision = 1): TokenPathSessionState {
return {
revision,
presentationVisible: {},
playback: {},
serverNowMs: Date.now(),
};
}