@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.
- package/CHANGELOG.md +26 -0
- package/dist/combat/effects.d.ts +11 -4
- package/dist/combat/effects.js +24 -17
- package/dist/combat/magazine.d.ts +13 -0
- package/dist/combat/magazine.js +27 -0
- package/dist/combat/vfxPresets.d.ts +176 -0
- package/dist/combat/vfxPresets.js +74 -0
- package/dist/combat/weaponFire.d.ts +114 -0
- package/dist/combat/weaponFire.js +96 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +3 -1
- package/dist/devtools/urlFlags.d.ts +22 -0
- package/dist/devtools/urlFlags.js +62 -0
- package/dist/game/clipRoles.d.ts +51 -0
- package/dist/game/clipRoles.js +142 -0
- package/dist/game/defineGame.d.ts +2 -2
- package/dist/game/partAnimation.d.ts +78 -0
- package/dist/game/partAnimation.js +147 -0
- package/dist/game/playableGame.d.ts +38 -5
- package/dist/game/progression.d.ts +2 -2
- package/dist/game/progression.js +4 -2
- package/dist/inventory/inventoryModel.d.ts +24 -8
- package/dist/inventory/inventoryModel.js +47 -8
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +25 -1
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/combatFx.js +15 -5
- package/dist/runtime/context/scene.d.ts +2 -1
- package/dist/runtime/context/scene.js +39 -1
- package/dist/runtime/gameContext.js +50 -4
- package/dist/runtime/gameContextTypes.d.ts +37 -4
- package/dist/runtime/headlessRunner.d.ts +13 -0
- package/dist/runtime/headlessRunner.js +14 -0
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/colliders.d.ts +21 -0
- package/dist/scene/colliders.js +62 -0
- package/dist/scene/entityStats.js +9 -1
- package/dist/stats/statModifiers.d.ts +16 -0
- package/dist/stats/statModifiers.js +30 -1
- package/dist/stats/statPool.js +5 -2
- package/dist/ui/panelModel.d.ts +120 -0
- package/dist/ui/panelModel.js +168 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/dist/world/buildingLots.d.ts +84 -0
- package/dist/world/buildingLots.js +119 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/cityGenerator.d.ts +41 -0
- package/dist/world/cityGenerator.js +58 -0
- package/dist/world/cityKind.d.ts +2 -2
- package/dist/world/cityKind.js +4 -4
- package/dist/world/environmentSummary.js +57 -1
- package/dist/world/features.d.ts +35 -2
- package/dist/world/features.js +5 -2
- package/dist/world/roads.d.ts +19 -2
- package/dist/world/roads.js +60 -3
- package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
- package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
- package/dist/world/streets.d.ts +74 -1
- package/dist/world/streets.js +218 -0
- package/dist/world/terrain.d.ts +20 -1
- package/dist/world/terrain.js +41 -2
- package/package.json +1 -1
- package/skills/jgengine/SKILL.md +1 -0
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-combat/SKILL.md +15 -1
- package/skills/jgengine-combat/api.md +39 -2
- package/skills/jgengine-combat/capabilities.md +17 -1
- package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
- package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
- package/skills/jgengine-combat/reference.md +22 -3
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +35 -5
- package/skills/jgengine-gameplay/capabilities.md +18 -0
- package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
- package/skills/jgengine-gameplay/reference-systems.md +9 -0
- package/skills/jgengine-world/api.md +52 -24
- package/skills/jgengine-world/capabilities.md +8 -4
- 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.
|
|
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;
|