@jgengine/core 0.12.0 → 0.13.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +25 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +2 -1
  29. package/dist/runtime/context/scene.js +39 -1
  30. package/dist/runtime/gameContext.js +50 -4
  31. package/dist/runtime/gameContextTypes.d.ts +37 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/colliders.d.ts +21 -0
  36. package/dist/scene/colliders.js +62 -0
  37. package/dist/scene/entityStats.js +9 -1
  38. package/dist/stats/statModifiers.d.ts +16 -0
  39. package/dist/stats/statModifiers.js +30 -1
  40. package/dist/stats/statPool.js +5 -2
  41. package/dist/ui/panelModel.d.ts +120 -0
  42. package/dist/ui/panelModel.js +168 -0
  43. package/dist/ui.d.ts +1 -0
  44. package/dist/ui.js +1 -0
  45. package/dist/world/buildingLots.d.ts +84 -0
  46. package/dist/world/buildingLots.js +119 -0
  47. package/dist/world/buildings.d.ts +6 -0
  48. package/dist/world/cityGenerator.d.ts +41 -0
  49. package/dist/world/cityGenerator.js +58 -0
  50. package/dist/world/cityKind.d.ts +2 -2
  51. package/dist/world/cityKind.js +4 -4
  52. package/dist/world/environmentSummary.js +57 -1
  53. package/dist/world/features.d.ts +35 -2
  54. package/dist/world/features.js +5 -2
  55. package/dist/world/roads.d.ts +19 -2
  56. package/dist/world/roads.js +60 -3
  57. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  58. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  59. package/dist/world/streets.d.ts +74 -1
  60. package/dist/world/streets.js +218 -0
  61. package/dist/world/terrain.d.ts +20 -1
  62. package/dist/world/terrain.js +41 -2
  63. package/package.json +1 -1
  64. package/skills/jgengine/SKILL.md +1 -0
  65. package/skills/jgengine/api.md +13 -5
  66. package/skills/jgengine-combat/SKILL.md +15 -1
  67. package/skills/jgengine-combat/api.md +39 -2
  68. package/skills/jgengine-combat/capabilities.md +17 -1
  69. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  70. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  71. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  72. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  73. package/skills/jgengine-combat/reference.md +22 -3
  74. package/skills/jgengine-gameplay/SKILL.md +8 -0
  75. package/skills/jgengine-gameplay/api.md +35 -5
  76. package/skills/jgengine-gameplay/capabilities.md +18 -0
  77. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  78. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  79. package/skills/jgengine-world/api.md +52 -24
  80. package/skills/jgengine-world/capabilities.md +8 -4
  81. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,96 @@
1
+ import { resolveDamageHit } from "./damageResolution.js";
2
+ import { createMagazine } from "./magazine.js";
3
+ /**
4
+ * Build a {@link FireCadence} rate gate. Reach for this instead of hand-tracking a
5
+ * `lastFiredAt` timestamp and comparing against a fire interval per game.
6
+ *
7
+ * @capability fire-cadence a serializable minimum-interval rate gate for repeated actions (weapon fire, ability spam)
8
+ */
9
+ export function createFireCadence(config) {
10
+ const intervalMs = Math.max(0, config.intervalMs);
11
+ let elapsed = config.ready === false ? 0 : intervalMs;
12
+ const ready = () => intervalMs <= 0 || elapsed >= intervalMs;
13
+ return {
14
+ ready,
15
+ readyFraction: () => (intervalMs <= 0 ? 1 : Math.min(1, elapsed / intervalMs)),
16
+ remainingMs: () => (ready() ? 0 : intervalMs - elapsed),
17
+ fire() {
18
+ if (!ready())
19
+ return false;
20
+ elapsed = 0;
21
+ return true;
22
+ },
23
+ tick(dtSeconds) {
24
+ if (dtSeconds <= 0 || elapsed >= intervalMs)
25
+ return;
26
+ elapsed = Math.min(intervalMs, elapsed + dtSeconds * 1000);
27
+ },
28
+ elapsedMs: () => elapsed,
29
+ restore(value) {
30
+ elapsed = Math.max(0, Math.min(intervalMs, value));
31
+ },
32
+ };
33
+ }
34
+ /**
35
+ * Compose a portable weapon fire controller from the existing generic primitives — a
36
+ * {@link FireCadence} gate, an optional {@link Magazine}, a caller-provided raycast, and portable
37
+ * {@link resolveDamageHit} damage resolution. No `GameContext`, entity store, renderer, or default
38
+ * weapon is involved; the caller owns aiming, its raycaster, and all presentation, and the result
39
+ * carries full damage provenance for authority to apply and replicate.
40
+ *
41
+ * @capability weapon-runtime compose cadence, magazine, a caller-owned raycast, and portable damage resolution into a fire controller
42
+ */
43
+ export function createWeaponRuntime(config) {
44
+ const cadence = createFireCadence({
45
+ intervalMs: config.cadenceMs,
46
+ ...(config.ready !== undefined ? { ready: config.ready } : {}),
47
+ });
48
+ const magazine = config.magazine === undefined ? null : createMagazine(config.magazine);
49
+ const loadedOf = () => (magazine === null ? null : magazine.loaded());
50
+ const blocked = (status) => ({
51
+ status,
52
+ hits: [],
53
+ resolutions: [],
54
+ loaded: loadedOf(),
55
+ });
56
+ const canFire = () => {
57
+ if (magazine !== null && magazine.isReloading())
58
+ return false;
59
+ if (!cadence.ready())
60
+ return false;
61
+ if (magazine !== null && !magazine.canFire(1))
62
+ return false;
63
+ return true;
64
+ };
65
+ return {
66
+ cadence,
67
+ magazine,
68
+ canFire,
69
+ startReload: () => (magazine === null ? false : magazine.startReload()),
70
+ cancelReload: () => magazine?.cancelReload(),
71
+ tick(dtSeconds) {
72
+ cadence.tick(dtSeconds);
73
+ magazine?.tick(dtSeconds);
74
+ },
75
+ fire(aim) {
76
+ if (magazine !== null && magazine.isReloading())
77
+ return blocked("reloading");
78
+ if (!cadence.ready())
79
+ return blocked("cooling");
80
+ if (magazine !== null && !magazine.canFire(1))
81
+ return blocked("empty");
82
+ cadence.fire();
83
+ magazine?.fire(1);
84
+ const hits = config.resolveHits(aim);
85
+ const resolutions = [];
86
+ for (const hit of hits) {
87
+ const input = config.damageFor(hit, aim);
88
+ if (input === null)
89
+ continue;
90
+ const withRng = config.rng !== undefined && input.rng === undefined ? { ...input, rng: config.rng } : input;
91
+ resolutions.push(resolveDamageHit(withRng));
92
+ }
93
+ return { status: "fired", hits, resolutions, loaded: loadedOf() };
94
+ },
95
+ };
96
+ }
package/dist/combat.d.ts CHANGED
@@ -16,7 +16,7 @@ export { createDownedState } from "./combat/downed.js";
16
16
  export { createEffectSystem, type ReceiveMap } from "./combat/effects.js";
17
17
  export { BUILTIN_COMPLETION_PREDICATES, BUILTIN_SPAWN_PROVIDERS, activePhaseId, createEncounterState, encounterProgress, forceCompletePhase, injectPhase, phaseStatus, startEncounter, updateEncounter, type CompletionPredicate, type EncounterConfig, type EncounterContext, type EncounterEvent, type EncounterPhase, type EncounterSignals, type EncounterSpawnRequest, type EncounterState, type EncounterStep, type PhaseCompletionRef, type PhaseInjectAt, type PhaseRuntime, type PhaseSpawnRef, type PhaseStatus, type PredicateContext, type SpawnProvider, type SpawnProviderContext, } from "./combat/encounterSequence.js";
18
18
  export { impactPresets, resolveHitReaction, type HitReactionConfig } from "./combat/hitReaction.js";
19
- export { createMagazine, type Magazine, type MagazineReserve } from "./combat/magazine.js";
19
+ export { createMagazine, type Magazine, type MagazineConfig, type MagazineReserve, type MagazineSnapshot, } from "./combat/magazine.js";
20
20
  export { createProjectileSystem, type ObjectRaycastHit, type ProjectileSystemDeps, type RaycastHit, } from "./combat/projectiles.js";
21
21
  export { resolveReceivedDamage, type DamageContext, type DamagePredicate, type ReceivedDamageResult, type ReceivedModifier, type ReceivedPolicy, } from "./combat/receivedDamage.js";
22
22
  export { createRegenShield } from "./combat/regenShield.js";
@@ -28,8 +28,10 @@ export { DEFAULT_EYE_HEIGHT, convergeShot, eyeHeightFromColliders, resolveShot,
28
28
  export { resolveStatusApplication, type StatusApplicationOutcome, type StatusApplicationSpec, type StatusImmunity, type StatusInstance, type StatusStackPolicy, } from "./combat/statusApplication.js";
29
29
  export { type TelegraphConfig, type TelegraphShape } from "./combat/telegraph.js";
30
30
  export { createThresholdTracker, type CrossingDirection, type CrossingPolicy, type CrossingTrigger, type ThresholdCrossing, type ThresholdCrossingConfig, type ThresholdMark, type ThresholdTracker, type ThresholdTrackerSnapshot, } from "./combat/thresholdCrossings.js";
31
+ export { DEFAULT_VFX_PRESET, resolveVfxPreset, vfxPresets, type VfxPreset, type VfxPresetName, } from "./combat/vfxPresets.js";
32
+ export { createFireCadence, createWeaponRuntime, type FireCadence, type FireCadenceConfig, type WeaponFireResult, type WeaponFireStatus, type WeaponRuntime, type WeaponRuntimeConfig, } from "./combat/weaponFire.js";
31
33
  export { createAccumulatorMeter, tierAt, type MeterAddResult } from "./stats/accumulatorMeter.js";
32
34
  export { createEventMeter, type EventMeter, type EventMeterFeedResult } from "./stats/eventMeter.js";
33
35
  export { rollCheck, type CheckAdvantage, type CheckResult } from "./stats/rollCheck.js";
34
- export { createStats, type Stats } from "./stats/statModifiers.js";
36
+ export { createStats, type Stats, type StatsSnapshot } from "./stats/statModifiers.js";
35
37
  export { applyStatPoolDelta, changeStatPool, createStatPool, patchStatPool, type StatPool, type StatPoolAccess, type StatPoolAccessResult, type StatPoolChange, type StatPoolInput, type StatPoolPatch, } from "./stats/statPool.js";
package/dist/combat.js CHANGED
@@ -16,7 +16,7 @@ export { createDownedState } from "./combat/downed.js";
16
16
  export { createEffectSystem } from "./combat/effects.js";
17
17
  export { BUILTIN_COMPLETION_PREDICATES, BUILTIN_SPAWN_PROVIDERS, activePhaseId, createEncounterState, encounterProgress, forceCompletePhase, injectPhase, phaseStatus, startEncounter, updateEncounter, } from "./combat/encounterSequence.js";
18
18
  export { impactPresets, resolveHitReaction } from "./combat/hitReaction.js";
19
- export { createMagazine } from "./combat/magazine.js";
19
+ export { createMagazine, } from "./combat/magazine.js";
20
20
  export { createProjectileSystem, } from "./combat/projectiles.js";
21
21
  export { resolveReceivedDamage, } from "./combat/receivedDamage.js";
22
22
  export { createRegenShield } from "./combat/regenShield.js";
@@ -28,6 +28,8 @@ export { DEFAULT_EYE_HEIGHT, convergeShot, eyeHeightFromColliders, resolveShot,
28
28
  export { resolveStatusApplication, } from "./combat/statusApplication.js";
29
29
  export {} from "./combat/telegraph.js";
30
30
  export { createThresholdTracker, } from "./combat/thresholdCrossings.js";
31
+ export { DEFAULT_VFX_PRESET, resolveVfxPreset, vfxPresets, } from "./combat/vfxPresets.js";
32
+ export { createFireCadence, createWeaponRuntime, } from "./combat/weaponFire.js";
31
33
  export { createAccumulatorMeter, tierAt } from "./stats/accumulatorMeter.js";
32
34
  export { createEventMeter } from "./stats/eventMeter.js";
33
35
  export { rollCheck } from "./stats/rollCheck.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * URL-backed debug flags — the single seam hosts use to mirror an engine debug surface (the scene
3
+ * editor, the devtools overlay) into the page query string. Mirroring means the URL is the honest
4
+ * record of what is open: share it to reproduce the state, and strip the param (then reload) to get
5
+ * a clean game with the surface gone. All functions no-op safely off the main browser thread.
6
+ */
7
+ /** The current query value of `param`, or null when absent (or no DOM). */
8
+ export declare function readUrlParam(param: string): string | null;
9
+ /** True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`). */
10
+ export declare function readUrlFlag(param: string): boolean;
11
+ /**
12
+ * Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with
13
+ * `history.replaceState` so the rest of the query and the hash survive and no history entry is
14
+ * pushed. No-ops without a DOM, and skips the write when the URL already matches.
15
+ */
16
+ export declare function writeUrlParam(param: string, value: string | null): void;
17
+ /**
18
+ * Fires `listener` whenever the query string may have changed out from under a flag — browser
19
+ * back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover
20
+ * those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
21
+ */
22
+ export declare function subscribeUrlChange(listener: () => void): () => void;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * URL-backed debug flags — the single seam hosts use to mirror an engine debug surface (the scene
3
+ * editor, the devtools overlay) into the page query string. Mirroring means the URL is the honest
4
+ * record of what is open: share it to reproduce the state, and strip the param (then reload) to get
5
+ * a clean game with the surface gone. All functions no-op safely off the main browser thread.
6
+ */
7
+ /** Values that read as "off" when a param is present but disabled — `?debug=0` stays closed. */
8
+ const FALSY = new Set(["", "0", "false", "off", "no"]);
9
+ /** The live browser window, or null when there is no DOM (SSR, tests, worker). */
10
+ function browserWindow() {
11
+ const candidate = globalThis.window;
12
+ if (candidate === undefined || candidate.location === undefined || candidate.history === undefined) {
13
+ return null;
14
+ }
15
+ return candidate;
16
+ }
17
+ /** The current query value of `param`, or null when absent (or no DOM). */
18
+ export function readUrlParam(param) {
19
+ const win = browserWindow();
20
+ if (win === null)
21
+ return null;
22
+ return new URLSearchParams(win.location.search).get(param);
23
+ }
24
+ /** True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`). */
25
+ export function readUrlFlag(param) {
26
+ const value = readUrlParam(param);
27
+ return value !== null && !FALSY.has(value.toLowerCase());
28
+ }
29
+ /**
30
+ * Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with
31
+ * `history.replaceState` so the rest of the query and the hash survive and no history entry is
32
+ * pushed. No-ops without a DOM, and skips the write when the URL already matches.
33
+ */
34
+ export function writeUrlParam(param, value) {
35
+ const win = browserWindow();
36
+ if (win === null)
37
+ return;
38
+ const url = new URL(win.location.href);
39
+ if (value === null) {
40
+ if (!url.searchParams.has(param))
41
+ return;
42
+ url.searchParams.delete(param);
43
+ }
44
+ else {
45
+ if (url.searchParams.get(param) === value)
46
+ return;
47
+ url.searchParams.set(param, value);
48
+ }
49
+ win.history.replaceState(win.history.state, "", url.toString());
50
+ }
51
+ /**
52
+ * Fires `listener` whenever the query string may have changed out from under a flag — browser
53
+ * back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover
54
+ * those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
55
+ */
56
+ export function subscribeUrlChange(listener) {
57
+ const win = browserWindow();
58
+ if (win === null)
59
+ return () => { };
60
+ win.addEventListener("popstate", listener);
61
+ return () => win.removeEventListener("popstate", listener);
62
+ }
@@ -0,0 +1,51 @@
1
+ import type { ModelAnimationConfig } from "./playableGame.js";
2
+ /**
3
+ * Semantic animation roles a GLB clip name can map to. Rigged packs name the same motion
4
+ * differently (`Idle` / `Walking_A` / `Armature|Run`); roles are the pack-agnostic vocabulary
5
+ * the engine defaults from.
6
+ */
7
+ export type ClipRole = "idle" | "walk" | "run" | "attack" | "hit" | "death" | "jump" | "spawn" | "interact" | "cheer";
8
+ /**
9
+ * Keyword table driving {@link classifyClip}: lowercase keywords matched against a clip name's
10
+ * tokens (exact token match beats prefix match). Extend or replace per game/pack — the table is
11
+ * plain data, no genre assumptions.
12
+ */
13
+ export interface ClipRoleTable {
14
+ readonly roles: Readonly<Partial<Record<ClipRole, readonly string[]>>>;
15
+ /** Tokens that disqualify a clip from classification entirely (e.g. `pose` for held-pose variants). */
16
+ readonly exclude?: readonly string[];
17
+ }
18
+ /** Default vocabulary covering KayKit, Quaternius, and Mixamo-style clip naming. */
19
+ export declare const DEFAULT_CLIP_ROLE_TABLE: ClipRoleTable;
20
+ /**
21
+ * Classify a single GLB clip name into a {@link ClipRole}, or `null` when nothing matches (or an
22
+ * exclusion token like `pose` disqualifies it). Deterministic: exact token matches beat prefix
23
+ * matches; remaining ties resolve by fixed role priority (death > hit > attack > … > idle).
24
+ *
25
+ * @capability clip-roles map a rigged asset's pack-specific clip name to its semantic animation role
26
+ */
27
+ export declare function classifyClip(name: string, table?: ClipRoleTable): ClipRole | null;
28
+ /**
29
+ * Group a GLB's clip names by role. Each role's variants are sorted simplest-first — fewest name
30
+ * tokens, then lexicographic — so the canonical variant (`Idle` over `2H_Melee_Idle`, KayKit
31
+ * `Walking_A` over `Walking_B`) is always first: stable output for the same clip set regardless
32
+ * of source order.
33
+ */
34
+ export declare function rolesFromClips(clips: readonly string[], table?: ClipRoleTable): Partial<Record<ClipRole, string[]>>;
35
+ /**
36
+ * Build the default `ModelConfig.animation` for a rigged asset from its catalog clip names:
37
+ * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable. Returns
38
+ * `undefined` when no idle clip is identifiable — an ambiguous rig renders its bind pose rather
39
+ * than guessing. Walk falls back to idle when the pack has no walk clip.
40
+ *
41
+ * @capability clip-roles derive a complete default animation config from a rigged asset's clip names
42
+ */
43
+ export declare function defaultAnimationForClips(clips: readonly string[], table?: ClipRoleTable): ModelAnimationConfig | undefined;
44
+ /**
45
+ * Collapse `ModelConfig.animation`'s widened union to a concrete playback config: explicit
46
+ * configs pass through, `"none"`/absent render the bind pose, and `"auto"` derives states and
47
+ * one-shots from the model's clip names via {@link defaultAnimationForClips}. The shell calls
48
+ * this with the loaded GLB's actual clip names, so `"auto"` works on any rigged model —
49
+ * catalog-resolved ids are stamped `"auto"` automatically.
50
+ */
51
+ export declare function resolveAnimationConfig(animation: ModelAnimationConfig | "auto" | "none" | undefined, clips: readonly string[] | undefined, table?: ClipRoleTable): ModelAnimationConfig | undefined;
@@ -0,0 +1,142 @@
1
+ /** Tie-break priority when a name matches several roles at equal strength ("Idle_Attack" → attack). */
2
+ const ROLE_PRIORITY = [
3
+ "death",
4
+ "hit",
5
+ "attack",
6
+ "jump",
7
+ "spawn",
8
+ "interact",
9
+ "cheer",
10
+ "run",
11
+ "walk",
12
+ "idle",
13
+ ];
14
+ /** Default vocabulary covering KayKit, Quaternius, and Mixamo-style clip naming. */
15
+ export const DEFAULT_CLIP_ROLE_TABLE = {
16
+ roles: {
17
+ idle: ["idle"],
18
+ walk: ["walk"],
19
+ run: ["run", "sprint", "jog"],
20
+ attack: ["attack", "punch", "slash", "shoot", "chop", "stab", "cast", "swing", "strike", "kick", "throw"],
21
+ hit: ["hit", "receive", "impact", "hurt", "damage"],
22
+ death: ["death", "die", "dead", "dying"],
23
+ jump: ["jump"],
24
+ spawn: ["spawn"],
25
+ interact: ["interact", "use", "pickup"],
26
+ cheer: ["cheer", "victory", "dance", "wave"],
27
+ },
28
+ exclude: ["pose"],
29
+ };
30
+ /** Lowercased tokens of a clip name: rig prefixes (`Armature|`, `mixamo.com|`) stripped, split on `_ - . space`. */
31
+ function tokenize(name) {
32
+ const segments = name.split("|");
33
+ const last = segments[segments.length - 1] ?? name;
34
+ return last
35
+ .toLowerCase()
36
+ .split(/[_\-. ]+/)
37
+ .filter((token) => token.length > 0);
38
+ }
39
+ /**
40
+ * Classify a single GLB clip name into a {@link ClipRole}, or `null` when nothing matches (or an
41
+ * exclusion token like `pose` disqualifies it). Deterministic: exact token matches beat prefix
42
+ * matches; remaining ties resolve by fixed role priority (death > hit > attack > … > idle).
43
+ *
44
+ * @capability clip-roles map a rigged asset's pack-specific clip name to its semantic animation role
45
+ */
46
+ export function classifyClip(name, table = DEFAULT_CLIP_ROLE_TABLE) {
47
+ const tokens = tokenize(name);
48
+ if (tokens.length === 0)
49
+ return null;
50
+ const exclude = table.exclude ?? [];
51
+ for (const token of tokens) {
52
+ if (exclude.includes(token))
53
+ return null;
54
+ }
55
+ let best = null;
56
+ for (const role of ROLE_PRIORITY) {
57
+ const keywords = table.roles[role] ?? [];
58
+ let score = 0;
59
+ for (const token of tokens) {
60
+ for (const keyword of keywords) {
61
+ if (token === keyword)
62
+ score = Math.max(score, 2);
63
+ else if (token.startsWith(keyword))
64
+ score = Math.max(score, 1);
65
+ }
66
+ }
67
+ if (score > 0 && (best === null || score > best.score))
68
+ best = { role, score };
69
+ }
70
+ return best?.role ?? null;
71
+ }
72
+ /**
73
+ * Group a GLB's clip names by role. Each role's variants are sorted simplest-first — fewest name
74
+ * tokens, then lexicographic — so the canonical variant (`Idle` over `2H_Melee_Idle`, KayKit
75
+ * `Walking_A` over `Walking_B`) is always first: stable output for the same clip set regardless
76
+ * of source order.
77
+ */
78
+ export function rolesFromClips(clips, table = DEFAULT_CLIP_ROLE_TABLE) {
79
+ const roles = {};
80
+ for (const clip of clips) {
81
+ const role = classifyClip(clip, table);
82
+ if (role === null)
83
+ continue;
84
+ (roles[role] ??= []).push(clip);
85
+ }
86
+ for (const variants of Object.values(roles)) {
87
+ variants.sort((a, b) => tokenize(a).length - tokenize(b).length || a.localeCompare(b));
88
+ }
89
+ return roles;
90
+ }
91
+ /** A role's variants as a one-shot binding: single name stays a string, several become a random-variant array. */
92
+ function oneShotSpec(variants) {
93
+ if (variants === undefined || variants.length === 0)
94
+ return undefined;
95
+ return variants.length === 1 ? variants[0] : variants;
96
+ }
97
+ /**
98
+ * Build the default `ModelConfig.animation` for a rigged asset from its catalog clip names:
99
+ * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable. Returns
100
+ * `undefined` when no idle clip is identifiable — an ambiguous rig renders its bind pose rather
101
+ * than guessing. Walk falls back to idle when the pack has no walk clip.
102
+ *
103
+ * @capability clip-roles derive a complete default animation config from a rigged asset's clip names
104
+ */
105
+ export function defaultAnimationForClips(clips, table = DEFAULT_CLIP_ROLE_TABLE) {
106
+ const roles = rolesFromClips(clips, table);
107
+ const idle = roles.idle?.[0];
108
+ if (idle === undefined)
109
+ return undefined;
110
+ const walk = roles.walk?.[0] ?? idle;
111
+ const run = roles.run?.[0];
112
+ const oneShots = {};
113
+ const hit = oneShotSpec(roles.hit);
114
+ const death = oneShotSpec(roles.death);
115
+ const attack = oneShotSpec(roles.attack);
116
+ if (hit !== undefined)
117
+ oneShots.hit = hit;
118
+ if (death !== undefined)
119
+ oneShots.death = death;
120
+ if (attack !== undefined)
121
+ oneShots.attack = attack;
122
+ return {
123
+ states: { idle, walk, ...(run === undefined ? {} : { run }) },
124
+ ...(Object.keys(oneShots).length === 0 ? {} : { oneShots }),
125
+ };
126
+ }
127
+ /**
128
+ * Collapse `ModelConfig.animation`'s widened union to a concrete playback config: explicit
129
+ * configs pass through, `"none"`/absent render the bind pose, and `"auto"` derives states and
130
+ * one-shots from the model's clip names via {@link defaultAnimationForClips}. The shell calls
131
+ * this with the loaded GLB's actual clip names, so `"auto"` works on any rigged model —
132
+ * catalog-resolved ids are stamped `"auto"` automatically.
133
+ */
134
+ export function resolveAnimationConfig(animation, clips, table = DEFAULT_CLIP_ROLE_TABLE) {
135
+ if (animation === undefined || animation === "none")
136
+ return undefined;
137
+ if (animation !== "auto")
138
+ return animation;
139
+ if (clips === undefined || clips.length === 0)
140
+ return undefined;
141
+ return defaultAnimationForClips(clips, table);
142
+ }
@@ -13,7 +13,7 @@ import { type EntityStore } from "../scene/entityStore.js";
13
13
  import type { StoreHandle } from "../store/defineStore.js";
14
14
  import type { TimeConfig } from "../time/simClock.js";
15
15
  import type { WorldFeature } from "../world/features.js";
16
- /** Tunes offline whole-world save (`defineGameDefinition({ persist })`). Defaults: continuous `autosave` to `localStorage`, one slot, no version. */
16
+ /** Tunes offline whole-world save (`defineGameDefinition({ persist })`). Offline games already autosave to `localStorage` by default — pass this only to tune it (or `persist: false` to opt out). Defaults: continuous `autosave` to `localStorage`, one slot, no version. */
17
17
  export interface PersistConfig {
18
18
  /** `"autosave"` (default) writes on a debounce after any change; `"manual"` writes only on `ctx.game.save.checkpoint()` (save points / quest triggers). */
19
19
  mode?: RuntimeSaveMode;
@@ -173,7 +173,7 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
173
173
  server?: GameServerConfig;
174
174
  /** Hosted per-player save policy, read only by an authoritative multiplayer host. Solo games leave this unset (nothing persists by default) and use `persist` when they want a save at all. */
175
175
  save?: SaveConfig;
176
- /** The one save knob for solo games: offline whole-world save. `true` autosaves the entire game to `localStorage`; a config object tunes the mode/cadence/target. Binds `ctx.game.save` — the game drives save points and restore. Ignored for multiplayer worlds (the host persists via `save`). */
176
+ /** The one save knob for solo games: offline whole-world save. **On by default** — an offline game autosaves the entire world to `localStorage` and binds `ctx.game.save` with no config at all; the game only drives save points and restore (`ctx.game.save.load()` at boot). Pass `false` to opt out entirely, or a config object to tune the mode/cadence/target (`storage: "memory"` for in-session-only). Ignored for multiplayer worlds (the host persists via `save`). */
177
177
  persist?: boolean | PersistConfig;
178
178
  ui?: unknown;
179
179
  loop?: GameLoop<GameContext>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Procedural animation for part-composed (rig-less) models — characters kit-bashed from
3
+ * primitives/`ModelPart`s with no skeleton or clips. Pure, deterministic curve sampling:
4
+ * the same (time, speed, phase, flinch, death) input always yields the same pose, so
5
+ * replays and multi-client renders stay in lockstep and the hot path allocates nothing
6
+ * when an `out` pose is reused.
7
+ */
8
+ /** Semantic motion role of one `ModelPart` in a part-composed character. */
9
+ export type PartRole = "head" | "arm.l" | "arm.r" | "leg.l" | "leg.r" | "tail" | "wing.l" | "wing.r";
10
+ /** Tuning for {@link samplePartPose}/{@link sampleBodyPose}; every field has a sensible default. */
11
+ export interface PartMotionParams {
12
+ /** Full stride cycles per second at walking speed. Default 1.6. */
13
+ strideHz?: number;
14
+ /** Limb swing amplitude in radians at full walk. Default 0.7. */
15
+ swingRad?: number;
16
+ /** Body bob height in model units at full walk. Default 0.04. */
17
+ bobAmp?: number;
18
+ /** Idle sway/breathe amplitude in radians. Default 0.05. */
19
+ swayRad?: number;
20
+ /** Speed (world units/sec) treated as a full walk; swing/bob scale linearly below it. Default 2. */
21
+ walkSpeed?: number;
22
+ /** Backward flinch pitch in radians at flinch = 1. Default 0.35. */
23
+ flinchRad?: number;
24
+ /** Sideways topple roll in radians at death = 1. Default PI / 2. */
25
+ toppleRad?: number;
26
+ /**
27
+ * Squash-and-stretch amplitude for soft characters (blobs, slimes): footfall squash while
28
+ * walking, jelly breathe while idle, a squash pulse on flinch. Volume-conserving — the body
29
+ * bulges horizontally as it compresses. Default 0 (rigid; scale stays exactly 1).
30
+ */
31
+ squashAmp?: number;
32
+ /** How death reads: `"topple"` rolls sideways (default, hard characters); `"splat"` flattens the body out — right for blobs. */
33
+ deathStyle?: "topple" | "splat";
34
+ }
35
+ /** Sampled offsets to add onto a part's authored transform (or the character root). `scale` is multiplicative (1 = unchanged). */
36
+ export interface PartPose {
37
+ position: [number, number, number];
38
+ rotation: [number, number, number];
39
+ scale: [number, number, number];
40
+ }
41
+ /** Live signals a driver feeds the samplers each frame. All values are render-side only. */
42
+ export interface PartMotionInput {
43
+ /** Seconds since the driver started (any monotonic clock). */
44
+ timeSec: number;
45
+ /** Horizontal speed in world units/sec (smoothed by the driver). */
46
+ speed: number;
47
+ /** Per-character phase offset in [0, 1) so crowds don't stride in sync — see {@link partMotionPhase}. */
48
+ phase: number;
49
+ /** Hit-flinch envelope in [0, 1]; the driver sets 1 on a hit and decays it. */
50
+ flinch: number;
51
+ /** Death ramp in [0, 1]; the driver eases it to 1 after the entity dies. */
52
+ death: number;
53
+ }
54
+ /** A reusable identity pose (zero offsets, unit scale). */
55
+ export declare function createPartPose(): PartPose;
56
+ /**
57
+ * Deterministic per-character stride phase in [0, 1) from an id (e.g. the entity instance id),
58
+ * so a crowd of identical part-composed characters doesn't animate in lockstep.
59
+ */
60
+ export declare function partMotionPhase(id: string): number;
61
+ /**
62
+ * Sample the character-root pose: walk bob, idle breathe/sway, backward hit flinch, and death —
63
+ * a sideways topple by default, or a flatten-out splat with `deathStyle: "splat"`. With
64
+ * `squashAmp > 0` the body also squashes and stretches (footfall squash, jelly idle breathe,
65
+ * flinch squash pulse), volume-conserving. A driver applies this to the group wrapping the
66
+ * whole composition (base model plus parts) — limbs ride along, as they would on a real torso.
67
+ *
68
+ * @capability part-motion sample the root bob/sway/squash/flinch/death pose for a rig-less part-composed character
69
+ */
70
+ export declare function sampleBodyPose(input: PartMotionInput, params?: PartMotionParams, out?: PartPose): PartPose;
71
+ /**
72
+ * Sample one part's pose offset by role: counter-phase leg/arm swing driven by speed, a head
73
+ * counter-sway, tail wag, and wing flap. Offsets are added onto the part's authored
74
+ * position/rotation. Motion fades to zero as `death` ramps so the topple reads as one piece.
75
+ *
76
+ * @capability part-motion sample a limb/head/tail/wing pose offset for a role-tagged ModelPart
77
+ */
78
+ export declare function samplePartPose(role: PartRole, input: PartMotionInput, params?: PartMotionParams, out?: PartPose): PartPose;