hayao 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -12
- package/dist/art/autotile.d.ts +77 -0
- package/dist/art/bitmapFont.d.ts +113 -0
- package/dist/art/font5.d.ts +11 -0
- package/dist/art/palette.d.ts +38 -0
- package/dist/art/texture.d.ts +78 -0
- package/dist/audio/audio.d.ts +7 -0
- package/dist/content/dsl.d.ts +61 -0
- package/dist/core/dmath.d.ts +20 -0
- package/dist/core/math.d.ts +2 -0
- package/dist/index.d.ts +37 -1
- package/dist/index.js +4549 -69
- package/dist/index.js.map +4 -4
- package/dist/logic/fsm.d.ts +85 -0
- package/dist/logic/graph.d.ts +88 -0
- package/dist/logic/history.d.ts +54 -0
- package/dist/logic/random.d.ts +32 -0
- package/dist/net/browser.d.ts +37 -0
- package/dist/net/inputBuffer.d.ts +27 -0
- package/dist/net/lockstep.d.ts +79 -0
- package/dist/net/players.d.ts +27 -0
- package/dist/net/protocol.d.ts +100 -0
- package/dist/net/rollback.d.ts +89 -0
- package/dist/net/room.d.ts +78 -0
- package/dist/net/transport.d.ts +78 -0
- package/dist/persist/codec.d.ts +4 -0
- package/dist/persist/save.d.ts +32 -0
- package/dist/persist/storage.d.ts +46 -0
- package/dist/physics/rigidBody.d.ts +104 -0
- package/dist/physics/rigidCollide.d.ts +16 -0
- package/dist/physics/rigidJoints.d.ts +65 -0
- package/dist/physics/rigidQueries.d.ts +15 -0
- package/dist/physics/rigidStep.d.ts +14 -0
- package/dist/procgen/cave.d.ts +21 -0
- package/dist/procgen/grid.d.ts +21 -0
- package/dist/procgen/rooms.d.ts +34 -0
- package/dist/procgen/scatter.d.ts +32 -0
- package/dist/procgen/terrain.d.ts +24 -0
- package/dist/render/nineSlice.d.ts +32 -0
- package/dist/scene/floatingText.d.ts +51 -0
- package/dist/scene/particles.d.ts +64 -0
- package/dist/scene/pool.d.ts +16 -0
- package/dist/scene/tween.d.ts +26 -0
- package/dist/ui/transition.d.ts +107 -0
- package/dist/verify/layout.d.ts +39 -0
- package/docs/API.md +247 -8
- package/package.json +31 -9
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Per-state lifecycle hooks. All optional; all pure over the shared context. */
|
|
2
|
+
export interface StateHandlers<C> {
|
|
3
|
+
/** Ran once when the machine enters this state. */
|
|
4
|
+
onEnter?(ctx: C): void;
|
|
5
|
+
/** Ran every `update(dt)` while in this state. */
|
|
6
|
+
onUpdate?(ctx: C, dt: number): void;
|
|
7
|
+
/** Ran once when the machine leaves this state. */
|
|
8
|
+
onLeave?(ctx: C): void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A guarded edge. `from` is a source state or `'*'` (any). Edges are evaluated
|
|
12
|
+
* in table order every tick; the first whose `when` returns true fires. Keep
|
|
13
|
+
* the table ordered — order IS the tie-break, so the machine stays hash-stable.
|
|
14
|
+
*/
|
|
15
|
+
export interface Transition<S extends string, C> {
|
|
16
|
+
from: S | '*';
|
|
17
|
+
to: S;
|
|
18
|
+
when(ctx: C): boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare class Fsm<S extends string, C> {
|
|
21
|
+
private readonly states;
|
|
22
|
+
private readonly transitions;
|
|
23
|
+
private readonly ctx;
|
|
24
|
+
/** The active state key — this is the whole serializable state. */
|
|
25
|
+
current: S;
|
|
26
|
+
constructor(states: Record<S, StateHandlers<C>>, transitions: readonly Transition<S, C>[], initial: S, ctx: C);
|
|
27
|
+
/**
|
|
28
|
+
* Advance one fixed step: fire the first satisfied transition (if any), then
|
|
29
|
+
* run the current state's `onUpdate`. A transition runs its target's
|
|
30
|
+
* `onUpdate` this same tick, so entering and acting aren't a frame apart.
|
|
31
|
+
*/
|
|
32
|
+
update(dt: number): void;
|
|
33
|
+
/** Force a transition (fires onLeave/onEnter). No-op if already there. */
|
|
34
|
+
go(to: S): void;
|
|
35
|
+
/** True when in one of the given states. */
|
|
36
|
+
is(...states: S[]): boolean;
|
|
37
|
+
/** Serialize (games persist this in `world.state`; the handlers are code). */
|
|
38
|
+
getState(): S;
|
|
39
|
+
/** Restore without firing enter/leave (it's a resume, not a transition). */
|
|
40
|
+
setState(state: S): void;
|
|
41
|
+
}
|
|
42
|
+
/** Eased progress curve `[0,1] → [0,1]`. Identity by default. */
|
|
43
|
+
export type Ease = (t: number) => number;
|
|
44
|
+
/** A timed phase: how long it lasts and (optionally) what follows it. */
|
|
45
|
+
export interface PhaseDef {
|
|
46
|
+
/** Duration in seconds (same units as the `dt` you feed `update`). */
|
|
47
|
+
duration: number;
|
|
48
|
+
/** Successor phase when this one elapses. Omit for a terminal phase. */
|
|
49
|
+
next?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A phase with a countdown and an eased progress readout. Drives discrete
|
|
53
|
+
* logical steps (each phase = one settled move) while exposing `progress()` for
|
|
54
|
+
* the view to interpolate between the old and new position — so animation
|
|
55
|
+
* timing rides on the same deterministic clock as the logic.
|
|
56
|
+
*/
|
|
57
|
+
export declare class PhaseClock {
|
|
58
|
+
private readonly defs;
|
|
59
|
+
/** Current phase key — serializable with `elapsed`. */
|
|
60
|
+
phase: string;
|
|
61
|
+
/** Seconds spent in the current phase so far. */
|
|
62
|
+
elapsed: number;
|
|
63
|
+
constructor(defs: Record<string, PhaseDef>, initial: string);
|
|
64
|
+
private get duration();
|
|
65
|
+
/** Eased fraction 0→1 through the current phase (clamped). For cosmetic interp. */
|
|
66
|
+
progress(ease?: Ease): number;
|
|
67
|
+
/** True once the current phase's duration has fully elapsed. */
|
|
68
|
+
get done(): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Advance by `dt`. When a phase completes and has a `next`, roll into it
|
|
71
|
+
* (carrying any overshoot so long phases don't drift). Returns true on the
|
|
72
|
+
* tick a new phase begins — the game's cue to commit the next logical step.
|
|
73
|
+
*/
|
|
74
|
+
update(dt: number): boolean;
|
|
75
|
+
/** Jump to a phase and reset its timer (fresh `onEnter`-style restart). */
|
|
76
|
+
to(phase: string): void;
|
|
77
|
+
getState(): {
|
|
78
|
+
phase: string;
|
|
79
|
+
elapsed: number;
|
|
80
|
+
};
|
|
81
|
+
setState(state: {
|
|
82
|
+
phase: string;
|
|
83
|
+
elapsed: number;
|
|
84
|
+
}): void;
|
|
85
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type TilemapData } from '../physics/tilemap';
|
|
2
|
+
/** Neighbours of a node, in deterministic order. */
|
|
3
|
+
export type Neighbours<N> = (node: N) => N[];
|
|
4
|
+
/** Stable string identity for a node (visited-set + path keys). */
|
|
5
|
+
export type NodeKey<N> = (node: N) => string;
|
|
6
|
+
export interface BfsResult<N> {
|
|
7
|
+
/** Nodes in expansion order (breadth-first). */
|
|
8
|
+
order: N[];
|
|
9
|
+
/** key → the node it was first reached from (for path reconstruction). */
|
|
10
|
+
cameFrom: Map<string, N>;
|
|
11
|
+
/** key → steps from the start (start = 0). */
|
|
12
|
+
dist: Map<string, number>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Breadth-first search from `start`. Explores the whole reachable component
|
|
16
|
+
* unless `goal` short-circuits it. `maxNodes` caps work on huge/open graphs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function bfs<N>(start: N, neighbours: Neighbours<N>, key: NodeKey<N>, opts?: {
|
|
19
|
+
goal?: (n: N) => boolean;
|
|
20
|
+
maxNodes?: number;
|
|
21
|
+
}): BfsResult<N>;
|
|
22
|
+
/** Reconstruct the path start→goal from a `cameFrom` map, or [] if unreachable. */
|
|
23
|
+
export declare function reconstructPath<N>(cameFrom: Map<string, N>, key: NodeKey<N>, start: N, goal: N): N[];
|
|
24
|
+
/** A neighbour with the step cost to reach it (must be ≥ 0). */
|
|
25
|
+
export interface WeightedEdge<N> {
|
|
26
|
+
node: N;
|
|
27
|
+
cost: number;
|
|
28
|
+
}
|
|
29
|
+
export type WeightedNeighbours<N> = (node: N) => WeightedEdge<N>[];
|
|
30
|
+
/**
|
|
31
|
+
* A* (Dijkstra when `heuristic` is omitted/zero) over a weighted adjacency
|
|
32
|
+
* function. Returns the least-cost path start→goal (inclusive) or null.
|
|
33
|
+
* Tie-broken by insertion order via a monotonic counter → identical path on
|
|
34
|
+
* every engine.
|
|
35
|
+
*/
|
|
36
|
+
export declare function astar<N>(start: N, isGoal: (n: N) => boolean, neighbours: WeightedNeighbours<N>, key: NodeKey<N>, opts?: {
|
|
37
|
+
heuristic?: (n: N) => number;
|
|
38
|
+
maxNodes?: number;
|
|
39
|
+
}): {
|
|
40
|
+
path: N[];
|
|
41
|
+
cost: number;
|
|
42
|
+
} | null;
|
|
43
|
+
export interface Cell {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
}
|
|
47
|
+
/** 4-neighbour offsets (N, E, S, W), in a fixed order. */
|
|
48
|
+
export declare const NEIGHBORS_4: readonly Cell[];
|
|
49
|
+
/** 8-neighbour offsets (orthogonals then diagonals), in a fixed order. */
|
|
50
|
+
export declare const NEIGHBORS_8: readonly Cell[];
|
|
51
|
+
/** Is a cell walkable? Called for in-bounds cells only. */
|
|
52
|
+
export type Passable = (x: number, y: number) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Flood-fill from a seed cell over `passable`, returning every reachable cell
|
|
55
|
+
* in breadth-first (deterministic) order. The classic bucket-fill / region-grab.
|
|
56
|
+
*/
|
|
57
|
+
export declare function floodFill(start: Cell, passable: Passable, opts: {
|
|
58
|
+
cols: number;
|
|
59
|
+
rows: number;
|
|
60
|
+
diagonal?: boolean;
|
|
61
|
+
}): Cell[];
|
|
62
|
+
export interface Components {
|
|
63
|
+
/** Row-major label per cell: -1 for impassable, else a component id ≥ 0. */
|
|
64
|
+
labels: number[];
|
|
65
|
+
/** Cells of each component, indexed by id, each in row-major order. */
|
|
66
|
+
cells: Cell[][];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Label connected regions of passable cells. Scans row-major and flood-fills
|
|
70
|
+
* each unlabeled passable cell, so component ids are assigned deterministically
|
|
71
|
+
* (top-left region first). Region/cluster detection for match-3, territory, etc.
|
|
72
|
+
*/
|
|
73
|
+
export declare function connectedComponents(cols: number, rows: number, passable: Passable, diagonal?: boolean): Components;
|
|
74
|
+
/**
|
|
75
|
+
* Grid A* / Dijkstra. Returns the least-cost cell path start→goal (inclusive)
|
|
76
|
+
* or null if unreachable. Default step cost is 1 (orthogonal) / √2 (diagonal);
|
|
77
|
+
* pass `cost(x,y)` to weight terrain. Heuristic defaults to Manhattan (4-dir)
|
|
78
|
+
* or octile (8-dir) — admissible, integer-stable, so paths reproduce exactly.
|
|
79
|
+
*/
|
|
80
|
+
export declare function astarGrid(start: Cell, goal: Cell, passable: Passable, opts: {
|
|
81
|
+
cols: number;
|
|
82
|
+
rows: number;
|
|
83
|
+
diagonal?: boolean;
|
|
84
|
+
cost?: (x: number, y: number) => number;
|
|
85
|
+
maxNodes?: number;
|
|
86
|
+
}): Cell[] | null;
|
|
87
|
+
/** Build a `Passable` from a tilemap: everything but SOLID/out-of-bounds walks. */
|
|
88
|
+
export declare function passableFromTilemap(map: TilemapData): Passable;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A memento stack for undo/redo over snapshots of pure state. Call `record()`
|
|
3
|
+
* after every committed move; `undo()`/`redo()` walk the cursor. States are
|
|
4
|
+
* cloned in and out so callers can freely mutate what they get back. Capacity is
|
|
5
|
+
* bounded — the oldest entry is dropped once `limit` is exceeded (undo has a
|
|
6
|
+
* horizon, memory doesn't grow without bound).
|
|
7
|
+
*/
|
|
8
|
+
export declare class UndoStack<S> {
|
|
9
|
+
private history;
|
|
10
|
+
private cursor;
|
|
11
|
+
private readonly limit;
|
|
12
|
+
private readonly clone;
|
|
13
|
+
constructor(opts?: {
|
|
14
|
+
limit?: number;
|
|
15
|
+
clone?: (s: S) => S;
|
|
16
|
+
});
|
|
17
|
+
/** Push a new state as the present, discarding any redo branch ahead of it. */
|
|
18
|
+
record(state: S): void;
|
|
19
|
+
/** Step back one state and return a clone of it (undefined if nothing to undo). */
|
|
20
|
+
undo(): S | undefined;
|
|
21
|
+
/** Step forward one state and return a clone of it (undefined if nothing to redo). */
|
|
22
|
+
redo(): S | undefined;
|
|
23
|
+
/** A clone of the current state, or undefined if empty. */
|
|
24
|
+
current(): S | undefined;
|
|
25
|
+
get canUndo(): boolean;
|
|
26
|
+
get canRedo(): boolean;
|
|
27
|
+
/** Number of recorded states retained. */
|
|
28
|
+
get size(): number;
|
|
29
|
+
clear(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A fixed-capacity ring buffer — the ghost/echo trail. Push a pose (or any value)
|
|
33
|
+
* each frame; when full, the oldest is overwritten. `toArray()` returns
|
|
34
|
+
* oldest→newest, which is exactly the order a trail renderer wants (tail first,
|
|
35
|
+
* head last). Pure and O(1) per push.
|
|
36
|
+
*/
|
|
37
|
+
export declare class RingBuffer<T> {
|
|
38
|
+
readonly capacity: number;
|
|
39
|
+
private readonly buf;
|
|
40
|
+
private start;
|
|
41
|
+
private count;
|
|
42
|
+
constructor(capacity: number);
|
|
43
|
+
push(item: T): void;
|
|
44
|
+
/** Number of items currently held (≤ capacity). */
|
|
45
|
+
get length(): number;
|
|
46
|
+
get isFull(): boolean;
|
|
47
|
+
/** Item by age index: 0 = oldest, length-1 = newest. Undefined if out of range. */
|
|
48
|
+
at(i: number): T | undefined;
|
|
49
|
+
/** The most recently pushed item, or undefined if empty. */
|
|
50
|
+
latest(): T | undefined;
|
|
51
|
+
/** Contents oldest→newest. */
|
|
52
|
+
toArray(): T[];
|
|
53
|
+
clear(): void;
|
|
54
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Rng } from '../core/rng';
|
|
2
|
+
/** One weighted option: a value and its relative weight (≥ 0). */
|
|
3
|
+
export interface WeightedEntry<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
weight: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Pick an index in [0, weights.length) with probability ∝ weights[i]. Draws
|
|
9
|
+
* exactly one `rng.float()` and walks the weights in order, so the choice is a
|
|
10
|
+
* pure function of the RNG state. Throws if no weight is positive.
|
|
11
|
+
*/
|
|
12
|
+
export declare function weightedIndex(rng: Rng, weights: readonly number[]): number;
|
|
13
|
+
/** Pick `items[i]` with probability ∝ `weights[i]` (parallel arrays). */
|
|
14
|
+
export declare function weightedPick<T>(rng: Rng, items: readonly T[], weights: readonly number[]): T;
|
|
15
|
+
/** Pick the value of one weighted entry. */
|
|
16
|
+
export declare function pickEntry<T>(rng: Rng, entries: readonly WeightedEntry<T>[]): T;
|
|
17
|
+
/**
|
|
18
|
+
* A reusable loot/spawn table. Precomputes cumulative weights once so repeated
|
|
19
|
+
* rolls are cheap; every draw still comes from the passed `Rng`, keeping the
|
|
20
|
+
* table itself stateless and the randomness owned by `world.rng`.
|
|
21
|
+
*/
|
|
22
|
+
export declare class LootTable<T> {
|
|
23
|
+
private readonly values;
|
|
24
|
+
/** Cumulative (prefix-sum) weights; last element is the total. */
|
|
25
|
+
private readonly cumulative;
|
|
26
|
+
readonly total: number;
|
|
27
|
+
constructor(entries: readonly WeightedEntry<T>[]);
|
|
28
|
+
/** One draw with replacement. */
|
|
29
|
+
roll(rng: Rng): T;
|
|
30
|
+
/** `n` independent draws with replacement, in order. */
|
|
31
|
+
rollMany(rng: Rng, n: number): T[];
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GameDefinition } from '../app/game';
|
|
2
|
+
import { KeyboardSource } from '../input/source';
|
|
3
|
+
import type { World } from '../world';
|
|
4
|
+
import type { Transport } from './transport';
|
|
5
|
+
import type { NetSessionConfig } from './protocol';
|
|
6
|
+
import type { NetGame, RoomCallbacks } from './room';
|
|
7
|
+
import type { PlayerId } from './players';
|
|
8
|
+
export interface NetRunOptions extends RoomCallbacks {
|
|
9
|
+
transport: Transport;
|
|
10
|
+
/** 'host' owns the lobby (first tab); 'join' connects to it. */
|
|
11
|
+
role: 'host' | 'join';
|
|
12
|
+
seed?: number;
|
|
13
|
+
config?: Partial<NetSessionConfig>;
|
|
14
|
+
maxPlayers?: number;
|
|
15
|
+
/** Re-attach behaviors after any restore (rollback / late join). */
|
|
16
|
+
attach?: (world: World) => void;
|
|
17
|
+
renderer?: 'svg' | 'canvas';
|
|
18
|
+
/** Lobby feedback ("waiting for players…", "connected as p2"). */
|
|
19
|
+
onStatus?: (status: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface NetGameHandle {
|
|
22
|
+
/** Host only: freeze the lobby and start the game. No-op for joiners. */
|
|
23
|
+
start(): void;
|
|
24
|
+
/** The live game once running (null while in the lobby). */
|
|
25
|
+
readonly game: NetGame | null;
|
|
26
|
+
readonly localPlayer: PlayerId | null;
|
|
27
|
+
readonly roster: PlayerId[];
|
|
28
|
+
input: KeyboardSource;
|
|
29
|
+
stop(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run a game as a networked session. Host flow: call, wait for peers
|
|
33
|
+
* (onPlayerJoin fires), then `handle.start()`. Join flow: call — the render
|
|
34
|
+
* loop begins automatically when the host starts (or immediately mid-game
|
|
35
|
+
* via snapshot late join).
|
|
36
|
+
*/
|
|
37
|
+
export declare function runBrowserNet(def: GameDefinition, mount: HTMLElement, opts: NetRunOptions): NetGameHandle;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PlayerId } from './players';
|
|
2
|
+
export declare class InputBuffer {
|
|
3
|
+
/** player → frame → actions (sorted). */
|
|
4
|
+
private frames;
|
|
5
|
+
/** player → highest contiguous frame received (for prediction + confirm). */
|
|
6
|
+
private contiguous;
|
|
7
|
+
addPlayer(player: PlayerId): void;
|
|
8
|
+
removePlayer(player: PlayerId): void;
|
|
9
|
+
players(): PlayerId[];
|
|
10
|
+
/**
|
|
11
|
+
* Record a player's actions for a frame. Returns true if this was new
|
|
12
|
+
* information (first write for that slot).
|
|
13
|
+
*/
|
|
14
|
+
set(player: PlayerId, frame: number, actions: readonly string[]): boolean;
|
|
15
|
+
has(player: PlayerId, frame: number): boolean;
|
|
16
|
+
get(player: PlayerId, frame: number): string[] | undefined;
|
|
17
|
+
/** The last actions at or before `frame` — the rollback predictor's guess. */
|
|
18
|
+
latestAt(player: PlayerId, frame: number): string[];
|
|
19
|
+
/** Highest frame such that this player's inputs 0..frame are all known. */
|
|
20
|
+
contiguousThrough(player: PlayerId): number;
|
|
21
|
+
/** Highest frame with ALL players' inputs known contiguously. */
|
|
22
|
+
confirmedFrame(): number;
|
|
23
|
+
/** Forget a player's frames at/after `frame` (leave cutoff). */
|
|
24
|
+
clearFrom(player: PlayerId, frame: number): void;
|
|
25
|
+
/** Drop history strictly before `frame` (memory hygiene on long sessions). */
|
|
26
|
+
prune(frame: number): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { World } from '../world';
|
|
2
|
+
import type { InputLog } from '../input/actions';
|
|
3
|
+
import { type PlayerId } from './players';
|
|
4
|
+
import { type NetSessionConfig } from './protocol';
|
|
5
|
+
import type { Transport } from './transport';
|
|
6
|
+
export interface DesyncInfo {
|
|
7
|
+
frame: number;
|
|
8
|
+
player: PlayerId;
|
|
9
|
+
localHash: string;
|
|
10
|
+
remoteHash: string;
|
|
11
|
+
/** Merged frames from this session's startFrame — replay fodder. */
|
|
12
|
+
log: InputLog;
|
|
13
|
+
startFrame: number;
|
|
14
|
+
}
|
|
15
|
+
export interface LockstepOptions {
|
|
16
|
+
world: World;
|
|
17
|
+
transport: Transport;
|
|
18
|
+
localPlayer: PlayerId;
|
|
19
|
+
/** Roster at startFrame, in canonical order. */
|
|
20
|
+
players: PlayerId[];
|
|
21
|
+
/** Frame this session begins at (0 for a fresh game, >0 for a late joiner). */
|
|
22
|
+
startFrame?: number;
|
|
23
|
+
config?: Partial<NetSessionConfig>;
|
|
24
|
+
onDesync?: (info: DesyncInfo) => void;
|
|
25
|
+
/** Fired when a step is blocked waiting on a player's input. */
|
|
26
|
+
onStall?: (player: PlayerId, frame: number) => void;
|
|
27
|
+
}
|
|
28
|
+
export declare class LockstepSession {
|
|
29
|
+
readonly world: World;
|
|
30
|
+
readonly localPlayer: PlayerId;
|
|
31
|
+
readonly config: NetSessionConfig;
|
|
32
|
+
readonly startFrame: number;
|
|
33
|
+
private readonly transport;
|
|
34
|
+
private readonly buffer;
|
|
35
|
+
private roster;
|
|
36
|
+
private mergedLog;
|
|
37
|
+
private localHashes;
|
|
38
|
+
private pendingRemoteHashes;
|
|
39
|
+
private sentThrough;
|
|
40
|
+
private stallCount;
|
|
41
|
+
private desynced;
|
|
42
|
+
private afterStepListeners;
|
|
43
|
+
private unsubscribe;
|
|
44
|
+
private readonly onDesync?;
|
|
45
|
+
private readonly onStall?;
|
|
46
|
+
constructor(opts: LockstepOptions);
|
|
47
|
+
activePlayers(frame: number): PlayerId[];
|
|
48
|
+
/** All peers must call this with the SAME atFrame (the room layer's job). */
|
|
49
|
+
addPlayer(player: PlayerId, atFrame: number): void;
|
|
50
|
+
/** All peers must call this with the SAME atFrame. */
|
|
51
|
+
removePlayer(player: PlayerId, atFrame: number): void;
|
|
52
|
+
get frame(): number;
|
|
53
|
+
/** Highest frame every currently-active player's input is known for. */
|
|
54
|
+
get confirmedFrame(): number;
|
|
55
|
+
get stalls(): number;
|
|
56
|
+
/**
|
|
57
|
+
* Try to advance exactly one frame with the local player's current actions.
|
|
58
|
+
* Returns 1 if the world stepped, 0 if blocked waiting for a remote input.
|
|
59
|
+
*/
|
|
60
|
+
tick(localActions?: readonly string[]): number;
|
|
61
|
+
/**
|
|
62
|
+
* Feed real elapsed ms (the browser loop's dt). Runs the fixed steps the
|
|
63
|
+
* clock grants — stalling drops time (the sim slows rather than desyncs) —
|
|
64
|
+
* then catches up if we're measurably behind the other peers.
|
|
65
|
+
*/
|
|
66
|
+
advance(realMs: number, localActions?: readonly string[]): number;
|
|
67
|
+
/** The merged input log from startFrame — replayable when startFrame is 0. */
|
|
68
|
+
inputLog(): InputLog;
|
|
69
|
+
dispose(): void;
|
|
70
|
+
/** Feed a raw transport message that arrived before this session existed. */
|
|
71
|
+
deliver(data: string): void;
|
|
72
|
+
/** Subscribe to "the world just reached frame N" (room layer uses this). */
|
|
73
|
+
onAfterStep(cb: (frame: number) => void): () => void;
|
|
74
|
+
private scheduleLocal;
|
|
75
|
+
private afterStep;
|
|
76
|
+
private receive;
|
|
77
|
+
private checkPendingHashes;
|
|
78
|
+
private remoteFrameEstimate;
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { World } from '../world';
|
|
2
|
+
/** A player identity: 'p1', 'p2', … (any short string without ':' works). */
|
|
3
|
+
export type PlayerId = string;
|
|
4
|
+
/** The canonical roster for n players: ['p1', …, 'pn']. */
|
|
5
|
+
export declare function playerIds(count: number): PlayerId[];
|
|
6
|
+
/** Namespace one action for a player: ('p2', 'left') → 'p2:left'. */
|
|
7
|
+
export declare const playerAction: (player: PlayerId, action: string) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Merge per-player action sets into one flat, sorted frame for `world.step()`.
|
|
10
|
+
* Deterministic: players are processed in roster order and the result is sorted,
|
|
11
|
+
* so every peer computes the identical frame from the same inputs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergePlayerFrames(players: readonly PlayerId[], inputs: ReadonlyMap<PlayerId, readonly string[]>): string[];
|
|
14
|
+
/**
|
|
15
|
+
* A per-player view over the world's input state. Game logic asks
|
|
16
|
+
* `player.isDown('left')` and never sees the namespacing.
|
|
17
|
+
*/
|
|
18
|
+
export declare class PlayerInput {
|
|
19
|
+
private readonly world;
|
|
20
|
+
readonly player: PlayerId;
|
|
21
|
+
constructor(world: World, player: PlayerId);
|
|
22
|
+
isDown(action: string): boolean;
|
|
23
|
+
justPressed(action: string): boolean;
|
|
24
|
+
justReleased(action: string): boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Convenience: the input view for one player. */
|
|
27
|
+
export declare const playerInput: (world: World, player: PlayerId) => PlayerInput;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { WorldSnapshot } from '../world';
|
|
2
|
+
import type { PlayerId } from './players';
|
|
3
|
+
export declare const NET_PROTOCOL_VERSION = 1;
|
|
4
|
+
/** Session tuning shared by every peer (the host dictates it in `welcome`). */
|
|
5
|
+
export interface NetSessionConfig {
|
|
6
|
+
mode: 'lockstep' | 'rollback';
|
|
7
|
+
/** Frames of input delay (lockstep) — hides transport latency. */
|
|
8
|
+
inputDelay: number;
|
|
9
|
+
/** Exchange a state hash every N frames to catch desyncs early. */
|
|
10
|
+
hashInterval: number;
|
|
11
|
+
/** Rollback: how many frames of history can be rewound. */
|
|
12
|
+
maxRollback: number;
|
|
13
|
+
/** How many recent frames each input message re-sends (loss tolerance). */
|
|
14
|
+
redundancy: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_SESSION_CONFIG: NetSessionConfig;
|
|
17
|
+
export type NetMessage =
|
|
18
|
+
/** A new endpoint asks to join. `uid` is its transport-unique id. */
|
|
19
|
+
{
|
|
20
|
+
v: number;
|
|
21
|
+
t: 'hello';
|
|
22
|
+
uid: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Host → one endpoint: your player id + everything needed to start/join. */
|
|
26
|
+
| {
|
|
27
|
+
v: number;
|
|
28
|
+
t: 'welcome';
|
|
29
|
+
to: string;
|
|
30
|
+
player: PlayerId;
|
|
31
|
+
players: PlayerId[];
|
|
32
|
+
seed: number;
|
|
33
|
+
config: NetSessionConfig;
|
|
34
|
+
/** Frame the joiner starts at (0 for a fresh game). */
|
|
35
|
+
startFrame: number;
|
|
36
|
+
/** Mid-game join: the world state at `startFrame`. */
|
|
37
|
+
snapshot?: WorldSnapshot;
|
|
38
|
+
/** Other joins announced but not yet live (their bootstrap frames). */
|
|
39
|
+
joins?: Array<{
|
|
40
|
+
player: PlayerId;
|
|
41
|
+
atFrame: number;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
/** Host → all: a player will exist from `atFrame` on (late join). */
|
|
45
|
+
| {
|
|
46
|
+
v: number;
|
|
47
|
+
t: 'join';
|
|
48
|
+
player: PlayerId;
|
|
49
|
+
atFrame: number;
|
|
50
|
+
}
|
|
51
|
+
/** Host → all: a player's inputs end at `atFrame` (disconnect/quit). */
|
|
52
|
+
| {
|
|
53
|
+
v: number;
|
|
54
|
+
t: 'leave';
|
|
55
|
+
player: PlayerId;
|
|
56
|
+
atFrame: number;
|
|
57
|
+
}
|
|
58
|
+
/** Host → all: begin simulating from frame 0 with this final roster. */
|
|
59
|
+
| {
|
|
60
|
+
v: number;
|
|
61
|
+
t: 'start';
|
|
62
|
+
players: PlayerId[];
|
|
63
|
+
}
|
|
64
|
+
/** Any endpoint → host: polite disconnect. */
|
|
65
|
+
| {
|
|
66
|
+
v: number;
|
|
67
|
+
t: 'bye';
|
|
68
|
+
uid: string;
|
|
69
|
+
}
|
|
70
|
+
/** Host → one endpoint: join refused (room full, mode without late join…). */
|
|
71
|
+
| {
|
|
72
|
+
v: number;
|
|
73
|
+
t: 'deny';
|
|
74
|
+
to: string;
|
|
75
|
+
reason: string;
|
|
76
|
+
}
|
|
77
|
+
/** Player inputs for frames [from, from+frames.length). Re-sends a small
|
|
78
|
+
* window so a dropped message doesn't stall anyone. */
|
|
79
|
+
| {
|
|
80
|
+
v: number;
|
|
81
|
+
t: 'input';
|
|
82
|
+
player: PlayerId;
|
|
83
|
+
from: number;
|
|
84
|
+
frames: string[][];
|
|
85
|
+
}
|
|
86
|
+
/** Structural world hash at a frame — cross-checked by every peer. */
|
|
87
|
+
| {
|
|
88
|
+
v: number;
|
|
89
|
+
t: 'hash';
|
|
90
|
+
player: PlayerId;
|
|
91
|
+
frame: number;
|
|
92
|
+
hash: string;
|
|
93
|
+
};
|
|
94
|
+
export declare function encodeMessage(msg: NetMessage): string;
|
|
95
|
+
/** Decode + shallow-validate. Returns null for junk or version mismatch. */
|
|
96
|
+
export declare function decodeMessage(data: string): NetMessage | null;
|
|
97
|
+
/** Build a message with the protocol version stamped in. */
|
|
98
|
+
export declare function netMessage<T extends Omit<NetMessage, 'v'>>(msg: T): T & {
|
|
99
|
+
v: number;
|
|
100
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { World } from '../world';
|
|
2
|
+
import type { InputLog } from '../input/actions';
|
|
3
|
+
import { type PlayerId } from './players';
|
|
4
|
+
import { type NetSessionConfig } from './protocol';
|
|
5
|
+
import type { Transport } from './transport';
|
|
6
|
+
import type { DesyncInfo } from './lockstep';
|
|
7
|
+
export interface RollbackOptions {
|
|
8
|
+
world: World;
|
|
9
|
+
transport: Transport;
|
|
10
|
+
localPlayer: PlayerId;
|
|
11
|
+
players: PlayerId[];
|
|
12
|
+
config?: Partial<NetSessionConfig>;
|
|
13
|
+
/** Re-attach behaviors/controllers after every restore (save/load hook). */
|
|
14
|
+
attach?: (world: World) => void;
|
|
15
|
+
onDesync?: (info: DesyncInfo) => void;
|
|
16
|
+
/** A correction arrived deeper than maxRollback — unrecoverable. */
|
|
17
|
+
onRollbackOverflow?: (frame: number) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare class RollbackSession {
|
|
20
|
+
readonly world: World;
|
|
21
|
+
readonly localPlayer: PlayerId;
|
|
22
|
+
readonly config: NetSessionConfig;
|
|
23
|
+
private readonly transport;
|
|
24
|
+
private readonly players;
|
|
25
|
+
private readonly buffer;
|
|
26
|
+
private readonly attach?;
|
|
27
|
+
private readonly onDesync?;
|
|
28
|
+
private readonly onRollbackOverflow?;
|
|
29
|
+
/** Snapshot AT frame f (state before stepping f), keyed f % ring length. */
|
|
30
|
+
private ring;
|
|
31
|
+
/** What we actually fed `step()` per frame — prediction bookkeeping. */
|
|
32
|
+
private usedInputs;
|
|
33
|
+
private earliestBad;
|
|
34
|
+
private rollbackCount;
|
|
35
|
+
private resimulatedFrames;
|
|
36
|
+
private localHashes;
|
|
37
|
+
/** player → cutoff frame: their inputs are [] from there on (departed). */
|
|
38
|
+
private dropped;
|
|
39
|
+
private pendingRemoteHashes;
|
|
40
|
+
private hashedThrough;
|
|
41
|
+
private desynced;
|
|
42
|
+
private overflowed;
|
|
43
|
+
private unsubscribe;
|
|
44
|
+
constructor(opts: RollbackOptions);
|
|
45
|
+
get frame(): number;
|
|
46
|
+
/** Highest frame all players' real inputs are known for (inclusive). */
|
|
47
|
+
get confirmedFrame(): number;
|
|
48
|
+
/** Highest contiguous frame received from `player` — the drop-cutoff basis. */
|
|
49
|
+
lastKnownFrame(player: PlayerId): number;
|
|
50
|
+
/**
|
|
51
|
+
* A player left (or vanished): their inputs are [] from `atFrame` on, so the
|
|
52
|
+
* session stops waiting on them. All peers must apply the SAME atFrame (the
|
|
53
|
+
* room layer broadcasts it). Frames already simulated with a non-empty
|
|
54
|
+
* prediction for the departed player roll back and re-simulate as usual.
|
|
55
|
+
*/
|
|
56
|
+
removePlayer(player: PlayerId, atFrame: number): void;
|
|
57
|
+
get stats(): {
|
|
58
|
+
rollbacks: number;
|
|
59
|
+
resimulatedFrames: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Advance one frame: publish local input, apply any pending correction,
|
|
63
|
+
* then step with confirmed-or-predicted inputs. Returns 1 unless the sim
|
|
64
|
+
* has outrun its rollback window (backpressure) or is desynced.
|
|
65
|
+
*/
|
|
66
|
+
tick(localActions?: readonly string[]): number;
|
|
67
|
+
/** Feed real elapsed ms; runs the fixed steps the clock grants. */
|
|
68
|
+
advance(realMs: number, localActions?: readonly string[]): number;
|
|
69
|
+
/** Merged CONFIRMED input log from frame 0 — replayable ground truth. */
|
|
70
|
+
inputLog(): InputLog;
|
|
71
|
+
dispose(): void;
|
|
72
|
+
/** Feed a raw transport message that arrived before this session existed. */
|
|
73
|
+
deliver(data: string): void;
|
|
74
|
+
private publishLocal;
|
|
75
|
+
private inputsFor;
|
|
76
|
+
private stepFrame;
|
|
77
|
+
/** Apply pending corrections. False = overflow (fatal). */
|
|
78
|
+
private resolveCorrections;
|
|
79
|
+
private storeSnapshot;
|
|
80
|
+
private receive;
|
|
81
|
+
/**
|
|
82
|
+
* Hash exchange runs on CONFIRMED frames only (predicted state is
|
|
83
|
+
* provisional by design). A snapshot has exactly the shape `world.hash()`
|
|
84
|
+
* hashes, so ring entries give us hashes of past frames for free.
|
|
85
|
+
*/
|
|
86
|
+
private settleHashes;
|
|
87
|
+
private compareHashes;
|
|
88
|
+
private pruneOld;
|
|
89
|
+
}
|