@jgengine/core 0.27.0 → 0.29.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/CHANGELOG.md +140 -0
- package/dist/ai/policeResponse.d.ts +253 -0
- package/dist/ai/policeResponse.js +421 -0
- package/dist/anim/actionLayer.d.ts +47 -0
- package/dist/anim/actionLayer.js +63 -0
- package/dist/anim/ikSolver.d.ts +14 -0
- package/dist/anim/ikSolver.js +37 -0
- package/dist/anim/locomotionGraph.d.ts +58 -3
- package/dist/anim/locomotionGraph.js +142 -15
- package/dist/anim/ragdollRig.d.ts +53 -0
- package/dist/anim/ragdollRig.js +132 -0
- package/dist/anim/skeletonStandard.d.ts +140 -0
- package/dist/anim/skeletonStandard.js +326 -0
- package/dist/combat/damageRuntime.d.ts +14 -1
- package/dist/combat/damageRuntime.js +62 -4
- package/dist/combat/death.js +7 -3
- package/dist/combat/deathReason.d.ts +7 -1
- package/dist/combat/effects.d.ts +25 -0
- package/dist/combat/effects.js +16 -0
- package/dist/combat/statusRuntime.d.ts +2 -0
- package/dist/combat/statusRuntime.js +1 -0
- package/dist/economy/loanBook.d.ts +182 -0
- package/dist/economy/loanBook.js +303 -0
- package/dist/economy/market.d.ts +161 -0
- package/dist/economy/market.js +248 -0
- package/dist/economy/transactionLedger.d.ts +162 -0
- package/dist/economy/transactionLedger.js +301 -0
- package/dist/editor/creatorStorage.js +3 -1
- package/dist/editor/simulation.d.ts +5 -2
- package/dist/editor/simulation.js +29 -19
- package/dist/game/cameraConfig.d.ts +14 -0
- package/dist/game/clipRoles.d.ts +5 -4
- package/dist/game/clipRoles.js +34 -4
- package/dist/game/events.d.ts +27 -0
- package/dist/game/gameSave.d.ts +142 -0
- package/dist/game/gameSave.js +174 -0
- package/dist/game/playableGame.d.ts +55 -4
- package/dist/game/saveSlotIndex.d.ts +54 -0
- package/dist/game/saveSlotIndex.js +71 -0
- package/dist/game/saveSlots.d.ts +2 -0
- package/dist/game/saveSlots.js +1 -1
- package/dist/game/saveStore.d.ts +8 -0
- package/dist/game/saveStore.js +16 -0
- package/dist/gameplay.d.ts +12 -2
- package/dist/gameplay.js +10 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +130 -1
- package/dist/mission/compositions.d.ts +85 -0
- package/dist/mission/compositions.js +130 -0
- package/dist/mission/mission.d.ts +435 -0
- package/dist/mission/mission.js +773 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/traffic.d.ts +0 -1
- package/dist/nav/traffic.js +1 -2
- package/dist/runtime/context/combat.js +11 -0
- package/dist/settings/accessibilitySettings.d.ts +81 -0
- package/dist/settings/accessibilitySettings.js +152 -0
- package/dist/settings/graphicsProfile.d.ts +3 -0
- package/dist/settings/graphicsProfile.js +4 -0
- package/dist/settings/settingsModel.d.ts +1 -1
- package/dist/settings/settingsModel.js +1 -0
- package/dist/sim/nearAgents.d.ts +8 -1
- package/dist/sim/nearAgents.js +34 -17
- package/dist/sim/offScreen.d.ts +2 -1
- package/dist/sim/offScreen.js +14 -3
- package/dist/sim/population.d.ts +147 -0
- package/dist/sim/population.js +182 -0
- package/dist/sim/queue.d.ts +90 -0
- package/dist/sim/queue.js +171 -0
- package/dist/sim/rides.d.ts +287 -0
- package/dist/sim/rides.js +471 -0
- package/dist/sim/routine.js +19 -11
- package/dist/ui/loadingTips.d.ts +41 -0
- package/dist/ui/loadingTips.js +64 -0
- package/dist/ui/menuNav.d.ts +79 -0
- package/dist/ui/menuNav.js +147 -0
- package/dist/ui/menuTransition.d.ts +50 -0
- package/dist/ui/menuTransition.js +67 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +2 -0
- package/dist/vfx/effectLibrary.d.ts +98 -0
- package/dist/vfx/effectLibrary.js +181 -0
- package/dist/vfx/particleBudget.d.ts +40 -0
- package/dist/vfx/particleBudget.js +39 -0
- package/dist/vfx/particleDirector.d.ts +16 -0
- package/dist/vfx/particleDirector.js +18 -0
- package/dist/world/authoredSimulation.js +7 -1
- package/dist/world/captureCamera.d.ts +30 -0
- package/dist/world/captureCamera.js +19 -0
- package/dist/world/features.d.ts +15 -4
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +2 -1
- package/dist/world.js +1 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +6 -0
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +55 -0
- package/skills/jgengine-gameplay/api.md +335 -10
- package/skills/jgengine-gameplay/capabilities.md +83 -1
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type SaveAutoConfig, type SaveBackend, type SaveStatus, type SaveTimers } from "./saveStore.js";
|
|
2
|
+
/** Current save-format version of {@link GameSaveDocument}. Bump it with {@link GameSaveConfig.migrate} when the shape changes. */
|
|
3
|
+
export declare const GAME_SAVE_VERSION = 1;
|
|
4
|
+
/**
|
|
5
|
+
* One stateful system inside a game save: a stable `name` (its section key in the document) and the
|
|
6
|
+
* `snapshot()`/`restore()` pair every Lane B module exposes. The name is the contract — a document
|
|
7
|
+
* written by one build restores into the next build's system with the same name. `reset` runs when a
|
|
8
|
+
* loaded slot has no section for this system (a fresh slot), so switching to an empty slot starts a
|
|
9
|
+
* new game instead of leaving the previous one's state behind; a system without it is left as-is.
|
|
10
|
+
*/
|
|
11
|
+
export interface GameSaveSystem<T = unknown> {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
snapshot(): T;
|
|
14
|
+
restore(next: T): void;
|
|
15
|
+
reset?(): void;
|
|
16
|
+
}
|
|
17
|
+
/** The persisted cursor of the world's shared injected RNG, plus the seed that produced it. */
|
|
18
|
+
export interface GameSaveRngState {
|
|
19
|
+
/** Seed the stream was created from, so a reload can rebuild the same sequence. */
|
|
20
|
+
seed: string | number;
|
|
21
|
+
/** The stream's cursor, or `null` for a generator that carries no state. */
|
|
22
|
+
cursor: number | null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The one save document: a format `version`, the game and clock metadata a save-select menu shows,
|
|
26
|
+
* the world's shared RNG cursor, and one section per registered system. There is deliberately no
|
|
27
|
+
* second per-system format — every system's state lives under `systems[<name>]`.
|
|
28
|
+
*/
|
|
29
|
+
export interface GameSaveDocument {
|
|
30
|
+
/** {@link GAME_SAVE_VERSION} this document was written at. */
|
|
31
|
+
version: number;
|
|
32
|
+
/** Stable game id, so a document names the game it belongs to. */
|
|
33
|
+
game: string;
|
|
34
|
+
/** Wall-clock time (ms) the document was captured. */
|
|
35
|
+
savedAt: number;
|
|
36
|
+
/** World clock (game seconds) at capture. */
|
|
37
|
+
time: number;
|
|
38
|
+
/** Seconds played at capture. */
|
|
39
|
+
playtime: number;
|
|
40
|
+
/** The shared RNG's seed and cursor, or `null` when the game injects no shared stream. */
|
|
41
|
+
rng: GameSaveRngState | null;
|
|
42
|
+
/** One entry per registered system, keyed by its name. */
|
|
43
|
+
systems: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
/** The world's shared injected RNG: its `seed` and the live `stream` whose cursor is persisted. */
|
|
46
|
+
export interface GameSaveRng {
|
|
47
|
+
readonly seed: string | number;
|
|
48
|
+
readonly stream: () => number;
|
|
49
|
+
}
|
|
50
|
+
/** How a {@link createGameSave} is wired. Storage is the narrow injected {@link SaveBackend}, never `localStorage` welded in. */
|
|
51
|
+
export interface GameSaveConfig {
|
|
52
|
+
/** Where the document is written — {@link localSaveBackend}, {@link memorySaveBackend}, or a remote/cloud backend. */
|
|
53
|
+
readonly backend: SaveBackend;
|
|
54
|
+
/** Stable game id, stored in every document. */
|
|
55
|
+
readonly game: string;
|
|
56
|
+
/** Systems whose sections make up the document. More can be added later with `register`. */
|
|
57
|
+
readonly systems?: readonly GameSaveSystem[];
|
|
58
|
+
/** Save-format version. Default {@link GAME_SAVE_VERSION}. */
|
|
59
|
+
readonly version?: number;
|
|
60
|
+
/** The world's shared injected RNG. Its seed and cursor are stored so the same sequence resumes. */
|
|
61
|
+
readonly rng?: GameSaveRng;
|
|
62
|
+
/** World clock reader, in game seconds. Default `() => 0`. */
|
|
63
|
+
readonly clock?: () => number;
|
|
64
|
+
/** Playtime reader, in seconds. Default `() => 0`. */
|
|
65
|
+
readonly playtime?: () => number;
|
|
66
|
+
/** Active slot. Default `"default"`. */
|
|
67
|
+
readonly slot?: string;
|
|
68
|
+
/** Storage namespace. Default `game-save:<game>`. */
|
|
69
|
+
readonly key?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Autosave cadence, as caller policy: `false` (default) writes only on an explicit `save()`;
|
|
72
|
+
* `true` debounces on the default window; an object sets its own debounce and ceiling.
|
|
73
|
+
*/
|
|
74
|
+
readonly autosave?: boolean | SaveAutoConfig;
|
|
75
|
+
/** Injectable clock (ms) for `savedAt`. Default `Date.now`. */
|
|
76
|
+
readonly now?: () => number;
|
|
77
|
+
/** Injectable timers for autosave. Default the ambient `setTimeout`/`clearTimeout`. */
|
|
78
|
+
readonly timers?: SaveTimers;
|
|
79
|
+
/**
|
|
80
|
+
* Upgrade an older document to the current shape. Receives the stored document and the version it
|
|
81
|
+
* was written at (`0` for a pre-versioned payload) and returns a document at {@link GameSaveConfig.version}.
|
|
82
|
+
*/
|
|
83
|
+
readonly migrate?: (document: unknown, fromVersion: number) => GameSaveDocument;
|
|
84
|
+
/** Notified on any backend read/write failure or a restored document written by another world. */
|
|
85
|
+
readonly onError?: (error: unknown) => void;
|
|
86
|
+
/** Register a devtools probe under this name so the overlay shows the slot, status and system count. */
|
|
87
|
+
readonly devtoolsName?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A pluggable-backend game save: one versioned document holding a section per system, with named
|
|
91
|
+
* slots, caller-policy autosave, migration and a state digest. `capture()` builds the document from
|
|
92
|
+
* the live systems; `save()`/`touch()` write it (directly or on the autosave debounce); `load()`
|
|
93
|
+
* reads a slot and applies every section back through its system's `restore`. The shared RNG's
|
|
94
|
+
* cursor rides along, so the seed survives the save and a reloaded run continues the same sequence.
|
|
95
|
+
*/
|
|
96
|
+
export interface GameSave {
|
|
97
|
+
/** Register a system after construction; returns an unregister fn. Throws on a duplicate name. */
|
|
98
|
+
register(system: GameSaveSystem): () => void;
|
|
99
|
+
/** Build the current document from every registered system. Does not write. */
|
|
100
|
+
capture(): GameSaveDocument;
|
|
101
|
+
/** Write the live state to the active slot now. */
|
|
102
|
+
save(): Promise<void>;
|
|
103
|
+
/** Read the active slot and apply every section to its system. */
|
|
104
|
+
load(): Promise<GameSaveDocument>;
|
|
105
|
+
/** Flush the current slot, switch, and load the next one. */
|
|
106
|
+
switchSlot(slot: string): Promise<GameSaveDocument>;
|
|
107
|
+
/**
|
|
108
|
+
* Read a slot's document without applying it to the live systems — the save-select menu's data
|
|
109
|
+
* source. Returns `null` for an empty slot.
|
|
110
|
+
*/
|
|
111
|
+
peek(slot: string): Promise<GameSaveDocument | null>;
|
|
112
|
+
/** Slots this save has written. */
|
|
113
|
+
slots(): Promise<string[]>;
|
|
114
|
+
slot(): string;
|
|
115
|
+
status(): SaveStatus;
|
|
116
|
+
/** Monotonic change counter — bumps on any value or status change. */
|
|
117
|
+
revision(): number;
|
|
118
|
+
/** Capture the live state and mark it dirty; a caller-policy autosave schedules the write. */
|
|
119
|
+
touch(): GameSaveDocument;
|
|
120
|
+
/** One hash over every section, the clock and the RNG cursor — what a determinism test compares. */
|
|
121
|
+
digest(): string;
|
|
122
|
+
/** Per-system section hashes, so a caller can see which systems the digest covers. */
|
|
123
|
+
digests(): Record<string, string>;
|
|
124
|
+
subscribe(listener: () => void): () => void;
|
|
125
|
+
dispose(): void;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A stable hash over the state-bearing part of a document — every system section, the world clock
|
|
129
|
+
* and the shared RNG cursor — excluding the wall-clock `savedAt`, which is not state.
|
|
130
|
+
*
|
|
131
|
+
* @capability game-save-digest hash a save document's state for a determinism test or a diff
|
|
132
|
+
*/
|
|
133
|
+
export declare function gameSaveDigest(document: GameSaveDocument): string;
|
|
134
|
+
/**
|
|
135
|
+
* Create a {@link GameSave}. Register every stateful system once; `capture()` writes one versioned
|
|
136
|
+
* document with a section per system, `save()`/`touch()` persist it through the injected backend
|
|
137
|
+
* (autosave cadence is the caller's), `load()` restores every section and the shared RNG cursor, and
|
|
138
|
+
* `digest()`/`digests()` give a determinism test a hash over the whole state.
|
|
139
|
+
*
|
|
140
|
+
* @capability game-save one versioned save document with per-system sections, named slots, caller-policy autosave, migration and a state digest
|
|
141
|
+
*/
|
|
142
|
+
export declare function createGameSave(config: GameSaveConfig): GameSave;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { hashString, restoreRng, rngStateOf } from "../random/rng.js";
|
|
3
|
+
import { createSaveStore, } from "./saveStore.js";
|
|
4
|
+
/** Current save-format version of {@link GameSaveDocument}. Bump it with {@link GameSaveConfig.migrate} when the shape changes. */
|
|
5
|
+
export const GAME_SAVE_VERSION = 1;
|
|
6
|
+
function stableStringify(value) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return "null";
|
|
9
|
+
const type = typeof value;
|
|
10
|
+
if (type === "number")
|
|
11
|
+
return Number.isFinite(value) ? JSON.stringify(value) : "null";
|
|
12
|
+
if (type === "boolean" || type === "string")
|
|
13
|
+
return JSON.stringify(value);
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
16
|
+
if (type === "object") {
|
|
17
|
+
const record = value;
|
|
18
|
+
const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort();
|
|
19
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
20
|
+
}
|
|
21
|
+
return "null";
|
|
22
|
+
}
|
|
23
|
+
function digestOf(value) {
|
|
24
|
+
return hashString(stableStringify(value)).toString(36);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A stable hash over the state-bearing part of a document — every system section, the world clock
|
|
28
|
+
* and the shared RNG cursor — excluding the wall-clock `savedAt`, which is not state.
|
|
29
|
+
*
|
|
30
|
+
* @capability game-save-digest hash a save document's state for a determinism test or a diff
|
|
31
|
+
*/
|
|
32
|
+
export function gameSaveDigest(document) {
|
|
33
|
+
return digestOf({
|
|
34
|
+
version: document.version,
|
|
35
|
+
game: document.game,
|
|
36
|
+
time: document.time,
|
|
37
|
+
playtime: document.playtime,
|
|
38
|
+
rng: document.rng,
|
|
39
|
+
systems: document.systems,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function emptyDocument(config, version) {
|
|
43
|
+
return {
|
|
44
|
+
version,
|
|
45
|
+
game: config.game,
|
|
46
|
+
savedAt: (config.now ?? Date.now)(),
|
|
47
|
+
time: config.clock?.() ?? 0,
|
|
48
|
+
playtime: config.playtime?.() ?? 0,
|
|
49
|
+
rng: null,
|
|
50
|
+
systems: {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a {@link GameSave}. Register every stateful system once; `capture()` writes one versioned
|
|
55
|
+
* document with a section per system, `save()`/`touch()` persist it through the injected backend
|
|
56
|
+
* (autosave cadence is the caller's), `load()` restores every section and the shared RNG cursor, and
|
|
57
|
+
* `digest()`/`digests()` give a determinism test a hash over the whole state.
|
|
58
|
+
*
|
|
59
|
+
* @capability game-save one versioned save document with per-system sections, named slots, caller-policy autosave, migration and a state digest
|
|
60
|
+
*/
|
|
61
|
+
export function createGameSave(config) {
|
|
62
|
+
const version = config.version ?? GAME_SAVE_VERSION;
|
|
63
|
+
const systems = new Map();
|
|
64
|
+
function add(system) {
|
|
65
|
+
if (systems.has(system.name))
|
|
66
|
+
throw new Error(`game-save: system "${system.name}" is already registered`);
|
|
67
|
+
systems.set(system.name, system);
|
|
68
|
+
}
|
|
69
|
+
for (const system of config.systems ?? [])
|
|
70
|
+
add(system);
|
|
71
|
+
function capture() {
|
|
72
|
+
const sections = {};
|
|
73
|
+
for (const [name, system] of systems)
|
|
74
|
+
sections[name] = system.snapshot();
|
|
75
|
+
return {
|
|
76
|
+
version,
|
|
77
|
+
game: config.game,
|
|
78
|
+
savedAt: (config.now ?? Date.now)(),
|
|
79
|
+
time: config.clock?.() ?? 0,
|
|
80
|
+
playtime: config.playtime?.() ?? 0,
|
|
81
|
+
rng: config.rng === undefined
|
|
82
|
+
? null
|
|
83
|
+
: { seed: config.rng.seed, cursor: rngStateOf(config.rng.stream) },
|
|
84
|
+
systems: sections,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function apply(document) {
|
|
88
|
+
const sections = document.systems ?? {};
|
|
89
|
+
for (const [name, system] of systems) {
|
|
90
|
+
if (Object.hasOwn(sections, name))
|
|
91
|
+
system.restore(sections[name]);
|
|
92
|
+
else
|
|
93
|
+
system.reset?.();
|
|
94
|
+
}
|
|
95
|
+
if (config.rng === undefined || document.rng === null)
|
|
96
|
+
return;
|
|
97
|
+
if (document.rng.seed !== config.rng.seed) {
|
|
98
|
+
config.onError?.(new Error(`game-save: document was written by world seed "${String(document.rng.seed)}", not "${String(config.rng.seed)}" — RNG cursor not restored`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (document.rng.cursor !== null)
|
|
102
|
+
restoreRng(config.rng.stream, document.rng.cursor);
|
|
103
|
+
}
|
|
104
|
+
const store = createSaveStore({
|
|
105
|
+
backend: config.backend,
|
|
106
|
+
initial: emptyDocument(config, version),
|
|
107
|
+
key: config.key ?? `game-save:${config.game}`,
|
|
108
|
+
slot: config.slot,
|
|
109
|
+
version,
|
|
110
|
+
autosave: config.autosave,
|
|
111
|
+
now: config.now,
|
|
112
|
+
timers: config.timers,
|
|
113
|
+
onError: config.onError,
|
|
114
|
+
migrate: (data, fromVersion) => {
|
|
115
|
+
if (fromVersion === version)
|
|
116
|
+
return data;
|
|
117
|
+
if (config.migrate !== undefined)
|
|
118
|
+
return { ...config.migrate(data, fromVersion), version };
|
|
119
|
+
return { ...data, version };
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const unregister = config.devtoolsName === undefined
|
|
123
|
+
? null
|
|
124
|
+
: devtools.probes.register(config.devtoolsName, () => `${store.slot()} ${store.status()} rev ${store.revision()} ${systems.size} systems`);
|
|
125
|
+
return {
|
|
126
|
+
register(system) {
|
|
127
|
+
add(system);
|
|
128
|
+
return () => systems.delete(system.name);
|
|
129
|
+
},
|
|
130
|
+
capture,
|
|
131
|
+
async save() {
|
|
132
|
+
store.set(capture());
|
|
133
|
+
await store.save();
|
|
134
|
+
},
|
|
135
|
+
async load() {
|
|
136
|
+
const document = await store.load();
|
|
137
|
+
apply(document);
|
|
138
|
+
return document;
|
|
139
|
+
},
|
|
140
|
+
async switchSlot(slot) {
|
|
141
|
+
store.set(capture());
|
|
142
|
+
const document = await store.switchSlot(slot);
|
|
143
|
+
apply(document);
|
|
144
|
+
return document;
|
|
145
|
+
},
|
|
146
|
+
async peek(slot) {
|
|
147
|
+
const stored = await store.peek(slot);
|
|
148
|
+
if (stored === null)
|
|
149
|
+
return null;
|
|
150
|
+
return stored.value;
|
|
151
|
+
},
|
|
152
|
+
slots: () => store.slots(),
|
|
153
|
+
slot: () => store.slot(),
|
|
154
|
+
status: () => store.status(),
|
|
155
|
+
revision: () => store.revision(),
|
|
156
|
+
touch() {
|
|
157
|
+
const document = capture();
|
|
158
|
+
store.set(document);
|
|
159
|
+
return document;
|
|
160
|
+
},
|
|
161
|
+
digest: () => gameSaveDigest(capture()),
|
|
162
|
+
digests() {
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [name, system] of systems)
|
|
165
|
+
out[name] = digestOf(system.snapshot());
|
|
166
|
+
return out;
|
|
167
|
+
},
|
|
168
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
169
|
+
dispose: () => {
|
|
170
|
+
unregister?.();
|
|
171
|
+
store.dispose();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -48,7 +48,7 @@ export interface NoticePresentationConfig extends NoticeFeedConfig {
|
|
|
48
48
|
/** Play a UI cue per new notice (deny → deny, success → confirm, else notify); a push with `silent: true` skips its own. Independent of the HUD control sounds (`audio.hudCues`). Default true. */
|
|
49
49
|
cues?: boolean;
|
|
50
50
|
}
|
|
51
|
-
export type { CameraFollowState, CameraKeyframe, CameraProjection, CameraRigKind, CameraWeaponView, CameraShakeConfig, ChaseCameraConfig, ChaseView, CinematicCameraConfig, FirstPersonCameraConfig, GameCameraConfig, InspectionCameraConfig, InspectionZoomAnchor, LockOnCameraConfig, ObserverCameraConfig, PlayerFovConfig, RtsCameraConfig, ShoulderCameraConfig, SideScrollCameraConfig, TopDownCameraConfig, TurntableCameraConfig, } from "./cameraConfig.js";
|
|
51
|
+
export type { CameraFollowState, CameraKeyframe, CameraProjection, CameraRigKind, CameraWeaponView, CameraShakeConfig, ChaseCameraConfig, ChaseView, CinematicCameraConfig, FirstPersonCameraConfig, FirstPersonCollisionConfig, GameCameraConfig, InspectionCameraConfig, InspectionZoomAnchor, LockOnCameraConfig, ObserverCameraConfig, PlayerFovConfig, RtsCameraConfig, ShoulderCameraConfig, SideScrollCameraConfig, TopDownCameraConfig, TurntableCameraConfig, } from "./cameraConfig.js";
|
|
52
52
|
export { CAMERA_FRUSTUM_DEFAULTS } from "./cameraConfig.js";
|
|
53
53
|
/**
|
|
54
54
|
* Mouse/touch pointer bindings for `defineGame({ pointer })`.
|
|
@@ -132,6 +132,16 @@ export interface ModelAnimationStates {
|
|
|
132
132
|
walk: string;
|
|
133
133
|
/** Clip name above `runSpeed`; omit to keep `walk` at any speed. */
|
|
134
134
|
run?: string;
|
|
135
|
+
/** Clip while moving to the character's left; with `strafeRight` adds the lateral strafe blend, driven by the `direction` parameter. */
|
|
136
|
+
strafeLeft?: string;
|
|
137
|
+
/** Clip while moving to the character's right; with `strafeLeft` adds the lateral strafe blend. */
|
|
138
|
+
strafeRight?: string;
|
|
139
|
+
/** Clip while moving backwards; adds the backward blend, driven by the `facing` parameter. */
|
|
140
|
+
backward?: string;
|
|
141
|
+
/** Clip while turning on the spot; omit to pivot on `walk`, driven by the `turnRate` parameter. */
|
|
142
|
+
turn?: string;
|
|
143
|
+
/** Play every locomotion state as root motion: the rig holds in place and the blended travel comes out as `rootDelta` for the movement owner to consume. */
|
|
144
|
+
rootMotion?: boolean;
|
|
135
145
|
/** Speed (world units/sec) above which the entity counts as moving. Default 0.5. */
|
|
136
146
|
walkSpeed?: number;
|
|
137
147
|
/** Speed above which `run` plays when provided. Default 6. */
|
|
@@ -287,6 +297,27 @@ export interface ModelConfig {
|
|
|
287
297
|
parts?: readonly ModelPart[];
|
|
288
298
|
/** Tuning for the procedural part-motion driver when any part carries a `role`; omit for defaults. */
|
|
289
299
|
partMotion?: PartMotionParams;
|
|
300
|
+
/**
|
|
301
|
+
* Distance tiers for a rigged character: how far away it still poses its skeleton every frame, and
|
|
302
|
+
* from where a far one renders through the GPU crowd path instead. `false` keeps this model at full
|
|
303
|
+
* rate everywhere, which is what a player avatar or a character in a cutscene wants. Omit for no
|
|
304
|
+
* distance tiers (full rate) — see {@link ModelLodConfig}.
|
|
305
|
+
*/
|
|
306
|
+
lod?: ModelLodConfig | false;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Distance tiers for one character model. Distances are metres from the camera; every field is data
|
|
310
|
+
* so a game can set its own thresholds per model and retune them without touching the engine.
|
|
311
|
+
*/
|
|
312
|
+
export interface ModelLodConfig {
|
|
313
|
+
/** Within this distance the rig poses every frame. Default 15. */
|
|
314
|
+
fullRateDistance?: number;
|
|
315
|
+
/** Within this distance the rig poses every other frame, carrying the accumulated time. Default 40. */
|
|
316
|
+
halfRateDistance?: number;
|
|
317
|
+
/** Beyond this distance a crowd-eligible rig renders through the GPU crowd path and updates no bones. Default 25. */
|
|
318
|
+
crowdDistance?: number;
|
|
319
|
+
/** Metres a character must come back inside `crowdDistance` before it returns to its own rig, so the swap cannot flicker at the boundary. Default 2. */
|
|
320
|
+
crowdHysteresis?: number;
|
|
290
321
|
}
|
|
291
322
|
/** Wind sway for a foliage model; see {@link ModelConfig.wind}. */
|
|
292
323
|
export interface ModelWindConfig {
|
|
@@ -313,10 +344,30 @@ export interface ModelIkConfig {
|
|
|
313
344
|
maxAdjust?: number;
|
|
314
345
|
/** Tilt planted feet to the ground normal, `0..1`. Default `1`. */
|
|
315
346
|
alignToGround?: number;
|
|
316
|
-
/** Turns this bone toward
|
|
317
|
-
lookAt?:
|
|
318
|
-
|
|
347
|
+
/** Turns this bone toward a target. */
|
|
348
|
+
lookAt?: ModelLookAtConfig;
|
|
349
|
+
}
|
|
350
|
+
/** {@link ModelIkConfig.lookAt}: the bone that turns, and what it turns toward. */
|
|
351
|
+
export interface ModelLookAtConfig {
|
|
352
|
+
/** Bone turned toward the target; the head on a humanoid rig. */
|
|
353
|
+
bone: string;
|
|
354
|
+
/**
|
|
355
|
+
* What the bone turns toward: the camera (default), another entity's position, or a fixed world
|
|
356
|
+
* point. An entity target that is not spawned this frame keeps the last known direction.
|
|
357
|
+
*/
|
|
358
|
+
target?: "camera" | {
|
|
359
|
+
entity: string;
|
|
360
|
+
} | {
|
|
361
|
+
point: readonly [number, number, number];
|
|
319
362
|
};
|
|
363
|
+
/** Height above an entity target's origin to aim at; the entity's eye level. Default 1.5. */
|
|
364
|
+
entityEyeHeight?: number;
|
|
365
|
+
/** Yaw limit in radians; the head stops turning past it. Omitted means unconstrained. */
|
|
366
|
+
maxYaw?: number;
|
|
367
|
+
/** Pitch limit in radians. Omitted means unconstrained. */
|
|
368
|
+
maxPitch?: number;
|
|
369
|
+
/** How much of the solved turn reaches the bone, `0..1`. Default `1`. */
|
|
370
|
+
weight?: number;
|
|
320
371
|
}
|
|
321
372
|
export interface ObjectStyle {
|
|
322
373
|
color?: string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GameSave, GameSaveDocument } from "./gameSave.js";
|
|
2
|
+
import type { SaveSlotMeta, SaveSlots } from "./saveSlots.js";
|
|
3
|
+
/**
|
|
4
|
+
* Bridging the versioned save document (`createGameSave`) to the save-select index (`createSaveSlots`):
|
|
5
|
+
* the document already carries `savedAt`, `time` and `playtime`, so a menu built on the index can show
|
|
6
|
+
* real saves instead of hand-maintained metadata.
|
|
7
|
+
*/
|
|
8
|
+
/** How one document becomes a menu slot. */
|
|
9
|
+
export interface SaveSlotMetaOptions {
|
|
10
|
+
/** Player-facing name for the slot. Defaults to the slot id. */
|
|
11
|
+
nameOf?: (slot: string, document: GameSaveDocument) => string;
|
|
12
|
+
/**
|
|
13
|
+
* Extra display chips, merged over the playtime chip. The game decides which document fields matter
|
|
14
|
+
* (`chapter`, `location`, `level`, …); the index never interprets meta keys.
|
|
15
|
+
*/
|
|
16
|
+
metaOf?: (slot: string, document: GameSaveDocument) => Record<string, string | number>;
|
|
17
|
+
}
|
|
18
|
+
/** "4h 12m" / "12m" / "< 1m" — the playtime chip every save menu wants. */
|
|
19
|
+
export declare function formatSavePlaytime(seconds: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Project one save document onto the index's display metadata: name, last-saved time and a playtime
|
|
22
|
+
* chip (plus any caller `metaOf` fields). Pure, so a menu can build its list from documents a host
|
|
23
|
+
* fetched for it.
|
|
24
|
+
*
|
|
25
|
+
* @capability save-slot-meta project a save document onto save-select display metadata
|
|
26
|
+
*/
|
|
27
|
+
export declare function saveSlotMeta(slot: string, document: GameSaveDocument, options?: SaveSlotMetaOptions): SaveSlotMeta;
|
|
28
|
+
/** The source a slot list is refreshed from — a `GameSave`, or a narrower reader. */
|
|
29
|
+
export interface SaveSlotSource {
|
|
30
|
+
slots(): Promise<string[]>;
|
|
31
|
+
peek(slot: string): Promise<GameSaveDocument | null>;
|
|
32
|
+
}
|
|
33
|
+
/** The result of a refresh: what the menu should show now. */
|
|
34
|
+
export interface SaveSlotsRefresh {
|
|
35
|
+
/** Written slots in the order the save store indexes them. */
|
|
36
|
+
saved: string[];
|
|
37
|
+
/** Slot metadata for every written slot. */
|
|
38
|
+
filled: SaveSlotMeta[];
|
|
39
|
+
/** Written slots the index could not hold (id not in it and the index is full). */
|
|
40
|
+
skipped: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read every written slot's document and write the display metadata into a `SaveSlots` index — the
|
|
44
|
+
* glue between B5.3's save document and a save-select menu. Slots the index already knows that have
|
|
45
|
+
* no document are cleared, so a deleted save stops showing a stale Continue.
|
|
46
|
+
*
|
|
47
|
+
* @capability save-slot-index refresh a save-select index from the game's save documents
|
|
48
|
+
*/
|
|
49
|
+
export declare function refreshSaveSlots(source: SaveSlotSource, model: SaveSlots, options?: SaveSlotMetaOptions): Promise<SaveSlotsRefresh>;
|
|
50
|
+
/**
|
|
51
|
+
* Adapt a {@link GameSave} as a {@link SaveSlotSource}. Kept separate so a host can supply its own
|
|
52
|
+
* reader (a cloud endpoint listing saves) with the same shape.
|
|
53
|
+
*/
|
|
54
|
+
export declare function gameSaveSlotSource(save: GameSave): SaveSlotSource;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** "4h 12m" / "12m" / "< 1m" — the playtime chip every save menu wants. */
|
|
2
|
+
export function formatSavePlaytime(seconds) {
|
|
3
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
4
|
+
const hours = Math.floor(total / 3600);
|
|
5
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
6
|
+
if (hours > 0)
|
|
7
|
+
return `${hours}h ${minutes}m`;
|
|
8
|
+
if (minutes > 0)
|
|
9
|
+
return `${minutes}m`;
|
|
10
|
+
return "< 1m";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Project one save document onto the index's display metadata: name, last-saved time and a playtime
|
|
14
|
+
* chip (plus any caller `metaOf` fields). Pure, so a menu can build its list from documents a host
|
|
15
|
+
* fetched for it.
|
|
16
|
+
*
|
|
17
|
+
* @capability save-slot-meta project a save document onto save-select display metadata
|
|
18
|
+
*/
|
|
19
|
+
export function saveSlotMeta(slot, document, options = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: slot,
|
|
22
|
+
name: options.nameOf?.(slot, document) ?? slot,
|
|
23
|
+
empty: false,
|
|
24
|
+
savedAt: document.savedAt,
|
|
25
|
+
meta: {
|
|
26
|
+
playtime: formatSavePlaytime(document.playtime),
|
|
27
|
+
...options.metaOf?.(slot, document),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read every written slot's document and write the display metadata into a `SaveSlots` index — the
|
|
33
|
+
* glue between B5.3's save document and a save-select menu. Slots the index already knows that have
|
|
34
|
+
* no document are cleared, so a deleted save stops showing a stale Continue.
|
|
35
|
+
*
|
|
36
|
+
* @capability save-slot-index refresh a save-select index from the game's save documents
|
|
37
|
+
*/
|
|
38
|
+
export async function refreshSaveSlots(source, model, options = {}) {
|
|
39
|
+
const saved = await source.slots();
|
|
40
|
+
const filled = [];
|
|
41
|
+
const skipped = [];
|
|
42
|
+
for (const slot of saved) {
|
|
43
|
+
const document = await source.peek(slot);
|
|
44
|
+
if (document === null)
|
|
45
|
+
continue;
|
|
46
|
+
const meta = saveSlotMeta(slot, document, options);
|
|
47
|
+
try {
|
|
48
|
+
model.write(slot, { name: meta.name, meta: meta.meta, savedAt: meta.savedAt });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
skipped.push(slot);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
filled.push(meta);
|
|
55
|
+
}
|
|
56
|
+
for (const existing of model.list()) {
|
|
57
|
+
if (!existing.empty && !filled.some((meta) => meta.id === existing.id))
|
|
58
|
+
model.clear(existing.id);
|
|
59
|
+
}
|
|
60
|
+
return { saved, filled, skipped };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Adapt a {@link GameSave} as a {@link SaveSlotSource}. Kept separate so a host can supply its own
|
|
64
|
+
* reader (a cloud endpoint listing saves) with the same shape.
|
|
65
|
+
*/
|
|
66
|
+
export function gameSaveSlotSource(save) {
|
|
67
|
+
return {
|
|
68
|
+
slots: () => save.slots(),
|
|
69
|
+
peek: (slot) => save.peek(slot),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/game/saveSlots.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface SaveSlotWrite {
|
|
|
29
29
|
name?: string;
|
|
30
30
|
/** Replacement free-string display fields. Omit to keep the existing `meta`. */
|
|
31
31
|
meta?: Record<string, string | number>;
|
|
32
|
+
/** The save's own write time (ms) when the payload knows it; omit to use the index's clock. */
|
|
33
|
+
savedAt?: number;
|
|
32
34
|
}
|
|
33
35
|
/** How a {@link createSaveSlots} index is wired. */
|
|
34
36
|
export interface SaveSlotsConfig {
|
package/dist/game/saveSlots.js
CHANGED
|
@@ -72,7 +72,7 @@ export function createSaveSlots(config = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
const slot = slots[i];
|
|
74
74
|
slot.empty = false;
|
|
75
|
-
slot.savedAt = now();
|
|
75
|
+
slot.savedAt = write?.savedAt ?? now();
|
|
76
76
|
if (write?.name !== undefined)
|
|
77
77
|
slot.name = write.name;
|
|
78
78
|
if (write?.meta !== undefined)
|
package/dist/game/saveStore.d.ts
CHANGED
|
@@ -92,6 +92,14 @@ export interface SaveStore<T> {
|
|
|
92
92
|
save(): Promise<void>;
|
|
93
93
|
/** Delete the active slot and reset `value()` to `initial`. */
|
|
94
94
|
clear(): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Read a stored slot without touching the live value, active slot or status — what a save-select
|
|
97
|
+
* menu shows. Returns `null` when the slot holds nothing.
|
|
98
|
+
*/
|
|
99
|
+
peek(slot?: string): Promise<{
|
|
100
|
+
value: T;
|
|
101
|
+
savedAt: number;
|
|
102
|
+
} | null>;
|
|
95
103
|
/** Flush any dirty state, switch to `slot`, and load it. */
|
|
96
104
|
switchSlot(slot: string): Promise<T>;
|
|
97
105
|
/** The slots this store has written (for a load/save menu). */
|
package/dist/game/saveStore.js
CHANGED
|
@@ -289,6 +289,22 @@ export function createSaveStore(config) {
|
|
|
289
289
|
firstDirtyAt = null;
|
|
290
290
|
return load();
|
|
291
291
|
},
|
|
292
|
+
async peek(slot = currentSlot) {
|
|
293
|
+
try {
|
|
294
|
+
const raw = await backend.read(dataKey(slot));
|
|
295
|
+
if (raw === null)
|
|
296
|
+
return null;
|
|
297
|
+
const parsed = deserialize(raw);
|
|
298
|
+
if (isEnvelope(parsed)) {
|
|
299
|
+
return { value: decode(raw), savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0 };
|
|
300
|
+
}
|
|
301
|
+
return { value: decode(raw), savedAt: 0 };
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
reportError(error);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
},
|
|
292
308
|
async slots() {
|
|
293
309
|
try {
|
|
294
310
|
return await readSlots();
|