@jgengine/core 0.26.0 → 0.28.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 (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Headless focus-order model for a game front-end menu: a list or grid of item ids, one focused
3
+ * item, and the keyboard/gamepad intents that move between them. It holds no React state, no DOM and
4
+ * no look — `@jgengine/react/menuNav` binds it to a container and real focus, and each game decides
5
+ * what the items are and where they sit.
6
+ */
7
+ /** Directions a navigation intent can move the focused item. */
8
+ export type MenuNavDirection = "up" | "down" | "left" | "right" | "first" | "last";
9
+ /** Everything a key or gamepad button can ask a menu to do. */
10
+ export type MenuIntent = MenuNavDirection | "confirm" | "back";
11
+ /** The shape of the item list being navigated: how many items, how they wrap into rows. */
12
+ export interface MenuNavLayout {
13
+ /** Number of items in the menu. */
14
+ count: number;
15
+ /**
16
+ * Items per row. `1` (default) is a vertical list, so up/down move one item and left/right do
17
+ * nothing. `count` is a single horizontal row. Any value in between is a grid.
18
+ */
19
+ columns?: number;
20
+ /** Wrap past the first/last item instead of stopping. Default `true`. */
21
+ wrap?: boolean;
22
+ /** Item indices that are skipped by every move (disabled or hidden rows). */
23
+ disabled?: readonly number[];
24
+ }
25
+ /**
26
+ * The next focused index for a direction, or the same index when the move is a no-op. Moves skip
27
+ * `disabled` indices and stop (rather than wrap) when every item in the way is disabled, so a menu
28
+ * can never hand focus to a dead row.
29
+ *
30
+ * @capability menu-nav-move move menu focus by direction over a list or grid, skipping disabled items
31
+ */
32
+ export declare function menuNavMove(index: number, direction: MenuNavDirection, layout: MenuNavLayout): number;
33
+ /** The index of `id` in the focus order, or `-1` when it is not part of this menu. */
34
+ export declare function menuNavIndex(ids: readonly string[], id: string | null | undefined): number;
35
+ /** The id at `index`, or `null` outside the list. */
36
+ export declare function menuNavIdAt(ids: readonly string[], index: number): string | null;
37
+ /** Keyboard codes that drive a menu, by `KeyboardEvent.code`. Tab is deliberately absent: it stays native. */
38
+ export declare const MENU_KEY_INTENTS: Readonly<Record<string, MenuIntent>>;
39
+ /**
40
+ * Resolve a `KeyboardEvent.code` to a menu intent, or `null` for keys the menu leaves alone (typing,
41
+ * Tab, shortcuts). Escape and Backspace both mean back; Space and Enter both mean confirm.
42
+ *
43
+ * @capability menu-key-intent resolve a keyboard code to a menu intent (move / confirm / back)
44
+ */
45
+ export declare function menuIntentForKey(code: string): MenuIntent | null;
46
+ /**
47
+ * Standard-mapping gamepad buttons as menu intents: A/Cross confirms, B/Circle and Start back, the
48
+ * d-pad moves. Buttons with no menu meaning return `null`.
49
+ *
50
+ * @capability menu-pad-intent resolve a standard gamepad button to a menu intent
51
+ */
52
+ export declare function menuIntentForPadButton(button: number): MenuIntent | null;
53
+ /**
54
+ * Resolve a left-stick axis sample to a direction. Axis `0` is horizontal and axis `1` vertical
55
+ * (standard mapping, up is negative); the larger magnitude wins so a diagonal push is one move.
56
+ */
57
+ export declare function menuIntentForPadAxis(axis: number, value: number, deadzone?: number): MenuNavDirection | null;
58
+ /** Hold-to-repeat timing for a held d-pad/stick: one immediate move, then repeats. */
59
+ export interface MenuNavRepeat {
60
+ /** Delay before the first repeat, ms. Default 320. */
61
+ delayMs?: number;
62
+ /** Delay between repeats, ms. Default 110. */
63
+ intervalMs?: number;
64
+ }
65
+ /** When a held direction was pressed and when it last moved the focus. */
66
+ export interface MenuNavHold {
67
+ /** Clock time (ms) the direction was first held. */
68
+ pressedAt: number;
69
+ /** Clock time (ms) of the last move it caused, or `null` before the first. */
70
+ lastFiredAt: number | null;
71
+ }
72
+ /**
73
+ * Whether a held direction should fire a move at `now`: immediately on press, then only after
74
+ * `delayMs`, and no more often than `intervalMs`. Pure timing, so a gamepad poll and a keydown
75
+ * handler share one rule.
76
+ *
77
+ * @capability menu-nav-repeat gate held menu navigation into an initial press plus repeats
78
+ */
79
+ export declare function shouldRepeatMenuNav(now: number, hold: MenuNavHold, repeat?: MenuNavRepeat): boolean;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Headless focus-order model for a game front-end menu: a list or grid of item ids, one focused
3
+ * item, and the keyboard/gamepad intents that move between them. It holds no React state, no DOM and
4
+ * no look — `@jgengine/react/menuNav` binds it to a container and real focus, and each game decides
5
+ * what the items are and where they sit.
6
+ */
7
+ function clamp(value, min, max) {
8
+ return value < min ? min : value > max ? max : value;
9
+ }
10
+ function isDisabled(layout, index) {
11
+ const disabled = layout.disabled;
12
+ return disabled !== undefined && disabled.includes(index);
13
+ }
14
+ /**
15
+ * The next focused index for a direction, or the same index when the move is a no-op. Moves skip
16
+ * `disabled` indices and stop (rather than wrap) when every item in the way is disabled, so a menu
17
+ * can never hand focus to a dead row.
18
+ *
19
+ * @capability menu-nav-move move menu focus by direction over a list or grid, skipping disabled items
20
+ */
21
+ export function menuNavMove(index, direction, layout) {
22
+ const { count } = layout;
23
+ if (count <= 0)
24
+ return -1;
25
+ const columns = Math.max(1, Math.min(layout.columns ?? 1, count));
26
+ const wrap = layout.wrap ?? true;
27
+ const start = clamp(index, 0, count - 1);
28
+ if (direction === "first" || direction === "last") {
29
+ const order = direction === "first" ? [...Array(count).keys()] : [...Array(count).keys()].reverse();
30
+ for (const candidate of order)
31
+ if (!isDisabled(layout, candidate))
32
+ return candidate;
33
+ return start;
34
+ }
35
+ const horizontal = direction === "left" || direction === "right";
36
+ if (horizontal && columns === 1)
37
+ return start;
38
+ const step = horizontal ? 1 : columns;
39
+ const delta = direction === "up" || direction === "left" ? -step : step;
40
+ let candidate = start;
41
+ for (let attempt = 0; attempt < count; attempt += 1) {
42
+ candidate += delta;
43
+ if (candidate < 0 || candidate >= count) {
44
+ if (!wrap)
45
+ return start;
46
+ candidate = candidate < 0 ? candidate + count : candidate - count;
47
+ }
48
+ if (horizontal && Math.floor(candidate / columns) !== Math.floor(start / columns))
49
+ return start;
50
+ if (!isDisabled(layout, candidate))
51
+ return candidate;
52
+ }
53
+ return start;
54
+ }
55
+ /** The index of `id` in the focus order, or `-1` when it is not part of this menu. */
56
+ export function menuNavIndex(ids, id) {
57
+ if (id === null || id === undefined)
58
+ return -1;
59
+ return ids.indexOf(id);
60
+ }
61
+ /** The id at `index`, or `null` outside the list. */
62
+ export function menuNavIdAt(ids, index) {
63
+ if (index < 0 || index >= ids.length)
64
+ return null;
65
+ return ids[index] ?? null;
66
+ }
67
+ /** Keyboard codes that drive a menu, by `KeyboardEvent.code`. Tab is deliberately absent: it stays native. */
68
+ export const MENU_KEY_INTENTS = {
69
+ ArrowUp: "up",
70
+ KeyW: "up",
71
+ ArrowDown: "down",
72
+ KeyS: "down",
73
+ ArrowLeft: "left",
74
+ KeyA: "left",
75
+ ArrowRight: "right",
76
+ KeyD: "right",
77
+ Home: "first",
78
+ End: "last",
79
+ Enter: "confirm",
80
+ NumpadEnter: "confirm",
81
+ Space: "confirm",
82
+ Escape: "back",
83
+ Backspace: "back",
84
+ };
85
+ /**
86
+ * Resolve a `KeyboardEvent.code` to a menu intent, or `null` for keys the menu leaves alone (typing,
87
+ * Tab, shortcuts). Escape and Backspace both mean back; Space and Enter both mean confirm.
88
+ *
89
+ * @capability menu-key-intent resolve a keyboard code to a menu intent (move / confirm / back)
90
+ */
91
+ export function menuIntentForKey(code) {
92
+ return MENU_KEY_INTENTS[code] ?? null;
93
+ }
94
+ /**
95
+ * Standard-mapping gamepad buttons as menu intents: A/Cross confirms, B/Circle and Start back, the
96
+ * d-pad moves. Buttons with no menu meaning return `null`.
97
+ *
98
+ * @capability menu-pad-intent resolve a standard gamepad button to a menu intent
99
+ */
100
+ export function menuIntentForPadButton(button) {
101
+ switch (button) {
102
+ case 0:
103
+ return "confirm";
104
+ case 1:
105
+ case 9:
106
+ return "back";
107
+ case 12:
108
+ return "up";
109
+ case 13:
110
+ return "down";
111
+ case 14:
112
+ return "left";
113
+ case 15:
114
+ return "right";
115
+ default:
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * Resolve a left-stick axis sample to a direction. Axis `0` is horizontal and axis `1` vertical
121
+ * (standard mapping, up is negative); the larger magnitude wins so a diagonal push is one move.
122
+ */
123
+ export function menuIntentForPadAxis(axis, value, deadzone = 0.55) {
124
+ if (Math.abs(value) < deadzone)
125
+ return null;
126
+ if (axis === 0)
127
+ return value > 0 ? "right" : "left";
128
+ if (axis === 1)
129
+ return value > 0 ? "down" : "up";
130
+ return null;
131
+ }
132
+ /**
133
+ * Whether a held direction should fire a move at `now`: immediately on press, then only after
134
+ * `delayMs`, and no more often than `intervalMs`. Pure timing, so a gamepad poll and a keydown
135
+ * handler share one rule.
136
+ *
137
+ * @capability menu-nav-repeat gate held menu navigation into an initial press plus repeats
138
+ */
139
+ export function shouldRepeatMenuNav(now, hold, repeat = {}) {
140
+ if (hold.lastFiredAt === null)
141
+ return true;
142
+ const delay = repeat.delayMs ?? 320;
143
+ const interval = repeat.intervalMs ?? 110;
144
+ if (now - hold.pressedAt < delay)
145
+ return false;
146
+ return now - hold.lastFiredAt >= interval;
147
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The phase timing behind an animated menu screen: closed → entering → open → exiting → closed. Pure
3
+ * timing over an injected clock, so a React block can animate with CSS and still know exactly when an
4
+ * exit finished and the screen may unmount. Reduced motion collapses every duration to zero instead
5
+ * of skipping the state machine, so callers never branch on it.
6
+ */
7
+ /** Where a menu screen is in its enter/exit animation. */
8
+ export type MenuTransitionPhase = "closed" | "enter" | "open" | "exit";
9
+ /** Durations and accessibility policy for one transition. */
10
+ export interface MenuTransitionConfig {
11
+ /** Enter duration, ms. Default 220. */
12
+ enterMs?: number;
13
+ /** Exit duration, ms. Default 160. */
14
+ exitMs?: number;
15
+ /** Collapse both durations to zero (the accessibility "reduced motion" preference). */
16
+ reducedMotion?: boolean;
17
+ }
18
+ /** Serializable phase state. */
19
+ export interface MenuTransitionSnapshot {
20
+ phase: MenuTransitionPhase;
21
+ /** Clock time (ms) the phase started. */
22
+ since: number;
23
+ }
24
+ /** Observable enter/exit phase machine. */
25
+ export interface MenuTransition {
26
+ state(): MenuTransitionSnapshot;
27
+ /** Begin entering from closed/exit, or go straight to open when motion is off. No-op when entering/open. */
28
+ open(now: number): void;
29
+ /** Begin exiting from entering/open. No-op when already exiting/closed. */
30
+ close(now: number): void;
31
+ /** Advance the machine to `now`; entering → open and exiting → closed happen here. */
32
+ tick(now: number): MenuTransitionSnapshot;
33
+ subscribe(listener: () => void): () => void;
34
+ snapshot(): MenuTransitionSnapshot;
35
+ restore(snapshot: MenuTransitionSnapshot): void;
36
+ }
37
+ /** The enter and exit durations after reduced-motion is applied. */
38
+ export declare function menuTransitionDurations(config?: MenuTransitionConfig): {
39
+ enter: number;
40
+ exit: number;
41
+ };
42
+ /** How long the current phase lasts, in ms (0 for open/closed). */
43
+ export declare function menuTransitionPhaseMs(phase: MenuTransitionPhase, config?: MenuTransitionConfig): number;
44
+ /**
45
+ * Create a {@link MenuTransition}. `open`/`close` start a phase; `tick` advances it once its duration
46
+ * has elapsed. Nothing schedules timers here — the caller ticks from its animation loop or a timeout.
47
+ *
48
+ * @capability menu-transition enter/exit phase timing for an animated menu screen, with reduced-motion collapse and snapshot/restore
49
+ */
50
+ export declare function createMenuTransition(config?: MenuTransitionConfig, initial?: MenuTransitionSnapshot): MenuTransition;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The phase timing behind an animated menu screen: closed → entering → open → exiting → closed. Pure
3
+ * timing over an injected clock, so a React block can animate with CSS and still know exactly when an
4
+ * exit finished and the screen may unmount. Reduced motion collapses every duration to zero instead
5
+ * of skipping the state machine, so callers never branch on it.
6
+ */
7
+ /** The enter and exit durations after reduced-motion is applied. */
8
+ export function menuTransitionDurations(config = {}) {
9
+ if (config.reducedMotion === true)
10
+ return { enter: 0, exit: 0 };
11
+ return { enter: Math.max(0, config.enterMs ?? 220), exit: Math.max(0, config.exitMs ?? 160) };
12
+ }
13
+ /** How long the current phase lasts, in ms (0 for open/closed). */
14
+ export function menuTransitionPhaseMs(phase, config = {}) {
15
+ const { enter, exit } = menuTransitionDurations(config);
16
+ return phase === "enter" ? enter : phase === "exit" ? exit : 0;
17
+ }
18
+ /**
19
+ * Create a {@link MenuTransition}. `open`/`close` start a phase; `tick` advances it once its duration
20
+ * has elapsed. Nothing schedules timers here — the caller ticks from its animation loop or a timeout.
21
+ *
22
+ * @capability menu-transition enter/exit phase timing for an animated menu screen, with reduced-motion collapse and snapshot/restore
23
+ */
24
+ export function createMenuTransition(config = {}, initial) {
25
+ let state = initial ?? { phase: "closed", since: 0 };
26
+ const listeners = new Set();
27
+ function commit(next) {
28
+ if (next.phase === state.phase && next.since === state.since)
29
+ return;
30
+ state = next;
31
+ for (const listener of listeners)
32
+ listener();
33
+ }
34
+ function open(now) {
35
+ if (state.phase === "enter" || state.phase === "open")
36
+ return;
37
+ commit({ phase: menuTransitionDurations(config).enter === 0 ? "open" : "enter", since: now });
38
+ }
39
+ function close(now) {
40
+ if (state.phase === "exit" || state.phase === "closed")
41
+ return;
42
+ commit({ phase: menuTransitionDurations(config).exit === 0 ? "closed" : "exit", since: now });
43
+ }
44
+ return {
45
+ state: () => state,
46
+ open,
47
+ close,
48
+ tick(now) {
49
+ const duration = menuTransitionPhaseMs(state.phase, config);
50
+ if (duration > 0 && now - state.since < duration)
51
+ return state;
52
+ if (state.phase === "enter")
53
+ commit({ phase: "open", since: now });
54
+ else if (state.phase === "exit")
55
+ commit({ phase: "closed", since: now });
56
+ return state;
57
+ },
58
+ subscribe(listener) {
59
+ listeners.add(listener);
60
+ return () => listeners.delete(listener);
61
+ },
62
+ snapshot: () => state,
63
+ restore(snapshot) {
64
+ commit({ phase: snapshot.phase, since: snapshot.since });
65
+ },
66
+ };
67
+ }
package/dist/ui.d.ts CHANGED
@@ -12,6 +12,8 @@ export { type GameLayoutMode, type GameViewportLayout, type HudPriority, type In
12
12
  export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPanelLayout, type EditorUiDocument, type EditorUiPanelLayout, type HudResizeAxes, } from "./ui/hudDocument.js";
13
13
  export { HUD_ANCHOR_FRACTIONS, type HudAnchor, type HudLayoutStore, type HudPlacement, type HudSize, } from "./ui/hudLayout.js";
14
14
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, type HudPlatform, type HudViewportConfig, } from "./ui/hudScale.js";
15
+ export { MENU_KEY_INTENTS, menuIntentForKey, menuIntentForPadAxis, menuIntentForPadButton, menuNavIdAt, menuNavIndex, menuNavMove, shouldRepeatMenuNav, type MenuIntent, type MenuNavDirection, type MenuNavHold, type MenuNavLayout, type MenuNavRepeat, } from "./ui/menuNav.js";
16
+ export { createMenuTransition, menuTransitionDurations, menuTransitionPhaseMs, type MenuTransition, type MenuTransitionConfig, type MenuTransitionPhase, type MenuTransitionSnapshot, } from "./ui/menuTransition.js";
15
17
  export { MODAL_CANCEL, MODAL_CONFIRM, createModalStack, type ModalInput, type ModalRecord, type ModalResolution, type ModalStack, type ModalStackEntry, type ModalStackOptions, type ModalStackSnapshot, } from "./ui/modalStack.js";
16
18
  export { createObjectiveBanner, type ObjectiveBannerAnnouncement, type ObjectiveBannerController, type ObjectiveBannerOptions, type ObjectiveBannerPhase, type ObjectiveBannerSnapshot, type ObjectiveBannerView, type StoredObjectiveBanner, } from "./ui/objectiveBanner.js";
17
19
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, type LayoutOrientation, } from "./ui/orientation.js";
package/dist/ui.js CHANGED
@@ -12,6 +12,8 @@ export {} from "./ui/gameLayout.js";
12
12
  export { listHudPanelTypes, registerHudPanelType, resizePanelSize, resolveHudPanelLayout, } from "./ui/hudDocument.js";
13
13
  export { HUD_ANCHOR_FRACTIONS, } from "./ui/hudLayout.js";
14
14
  export { hudScaleForViewport, overflowingPanels, resolveHudFit, } from "./ui/hudScale.js";
15
+ export { MENU_KEY_INTENTS, menuIntentForKey, menuIntentForPadAxis, menuIntentForPadButton, menuNavIdAt, menuNavIndex, menuNavMove, shouldRepeatMenuNav, } from "./ui/menuNav.js";
16
+ export { createMenuTransition, menuTransitionDurations, menuTransitionPhaseMs, } from "./ui/menuTransition.js";
15
17
  export { MODAL_CANCEL, MODAL_CONFIRM, createModalStack, } from "./ui/modalStack.js";
16
18
  export { createObjectiveBanner, } from "./ui/objectiveBanner.js";
17
19
  export { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "./ui/orientation.js";
@@ -0,0 +1,98 @@
1
+ import type { ParticleAttachOptions, ParticleBlendHint, ParticleRenderHint } from "./particleDirector.js";
2
+ import type { EmitterConfig, Vec3 } from "./particles.js";
3
+ /** The family an effect belongs to; groups the library browser and the contact sheet. */
4
+ export type EffectGroup = "fire" | "smoke" | "sparks" | "dust" | "splash" | "muzzle" | "explosion" | "magic" | "weather";
5
+ /**
6
+ * The procedural sprite family a particle draws with on the GPU path. Each family maps to a
7
+ * {@link ParticleRenderHint} shape: `soft` puffs, velocity-stretched `spark` streaks, `flake`
8
+ * crystals, `flame` tongues, `smoke` puffs, connected `ribbon` trails and flat `ring` ripples.
9
+ * There is no image file to load or credit — the field is the authored name for the sprite.
10
+ */
11
+ export type EffectTexture = "soft" | "spark" | "flake" | "flame" | "smoke" | "ribbon" | "ring";
12
+ /**
13
+ * One named effect a game or the editor plays by name. Every field is data: the `config` is the
14
+ * same `EmitterConfig` a hand-authored emitter uses, so a library effect serializes, retunes and
15
+ * renders on the existing pooled GPU path without a second implementation.
16
+ */
17
+ export interface ParticleEffect {
18
+ /** Stable name, e.g. `fire/flame`. Names are the data contract; configs are not. */
19
+ name: string;
20
+ /** Human label for pickers and contact sheets. */
21
+ title: string;
22
+ group: EffectGroup;
23
+ /**
24
+ * Drop order under the graphics-tier particle budget: higher survives, lower is dropped first.
25
+ * Gameplay-loud effects (explosions, muzzle flashes) outrank ambient dressing (dust, weather).
26
+ */
27
+ priority: number;
28
+ /** `continuous` emits from `rate`; `burst` emits `burst` particles per play. */
29
+ mode: "continuous" | "burst";
30
+ /** Particles emitted per one-shot `play`, and the pool size a standing emitter may reach. */
31
+ burst: number;
32
+ texture: EffectTexture;
33
+ /** Velocity streak length in seconds for `spark`/`ribbon` textures. Default 0.08. */
34
+ stretch?: number;
35
+ blending: ParticleBlendHint;
36
+ config: EmitterConfig;
37
+ /** Attachment defaults (follow, offset, space, collision child, environment influence). */
38
+ options?: Omit<ParticleAttachOptions, "priority">;
39
+ }
40
+ /** Ambient dressing drops first, then gameplay-loud effects last. */
41
+ export declare const EFFECT_PRIORITY: {
42
+ readonly weather: 20;
43
+ readonly dust: 40;
44
+ readonly smoke: 60;
45
+ readonly magic: 70;
46
+ readonly sparks: 70;
47
+ readonly fire: 80;
48
+ readonly splash: 80;
49
+ readonly muzzle: 90;
50
+ readonly explosion: 90;
51
+ };
52
+ /**
53
+ * The shared library of named particle effects: fire, smoke, sparks, dust, splashes, muzzle flash,
54
+ * explosions, magic and weather. Each entry is authored parameters only — rate, lifetime, size,
55
+ * colour and the sprite texture — so a game calls `ctx.particles.play("explosion/core", at)` and
56
+ * the editor authors the same name instead of hand-tuning an `EmitterConfig`. Names are the
57
+ * contract; a config retune here changes every consumer deliberately.
58
+ */
59
+ export declare const EFFECT_LIBRARY: readonly ParticleEffect[];
60
+ /** The fallback effect used when a play call names an unknown effect — a typo still shows a spark. */
61
+ export declare const DEFAULT_EFFECT = "sparks/weld";
62
+ /** Every library effect name, for editor pickers and runtime discovery. */
63
+ export declare function effectNames(): readonly string[];
64
+ /** The named effect, or `undefined` when the name is not in the library.
65
+ * @capability find-effect look up one named particle effect in the shared library, or undefined when unknown
66
+ */
67
+ export declare function findEffect(name: string): ParticleEffect | undefined;
68
+ /** Per-call overrides merged over a library effect; each field wins over the authored default. */
69
+ export interface EffectOverride {
70
+ config?: Partial<EmitterConfig>;
71
+ options?: Partial<ParticleAttachOptions>;
72
+ /** World position, or the standing emitter's origin when it does not follow an entity. */
73
+ position?: Vec3;
74
+ /** One-shot particle count; overrides the effect's authored `burst`. */
75
+ burst?: number;
76
+ }
77
+ /** A library effect resolved into the exact data the director and renderer consume. */
78
+ export interface ResolvedEffect {
79
+ name: string;
80
+ group: EffectGroup;
81
+ mode: ParticleEffect["mode"];
82
+ priority: number;
83
+ /** Particles a one-shot `play` emits. */
84
+ burst: number;
85
+ config: EmitterConfig;
86
+ render: ParticleRenderHint;
87
+ blending: ParticleBlendHint;
88
+ /** Attach options including the resolved `priority`, ready for the director. */
89
+ options: ParticleAttachOptions;
90
+ }
91
+ /**
92
+ * Resolve a named effect into concrete emitter data, with any `override` winning field by field.
93
+ * An unknown name resolves to {@link DEFAULT_EFFECT} rather than throwing, so a typo shows
94
+ * *something*; call {@link findEffect} when the caller must reject an unknown name.
95
+ *
96
+ * @capability effect-resolve turn a named library effect into emitter config, GPU sprite texture, blending and budget priority with per-call overrides
97
+ */
98
+ export declare function resolveEffect(name: string, override?: EffectOverride): ResolvedEffect;
@@ -0,0 +1,181 @@
1
+ const TEXTURE_SHAPE = {
2
+ soft: "point",
3
+ spark: "streak",
4
+ flake: "flake",
5
+ flame: "flame",
6
+ smoke: "smoke",
7
+ ribbon: "ribbon",
8
+ ring: "ripple",
9
+ };
10
+ /** Ambient dressing drops first, then gameplay-loud effects last. */
11
+ export const EFFECT_PRIORITY = {
12
+ weather: 20,
13
+ dust: 40,
14
+ smoke: 60,
15
+ magic: 70,
16
+ sparks: 70,
17
+ fire: 80,
18
+ splash: 80,
19
+ muzzle: 90,
20
+ explosion: 90,
21
+ };
22
+ /**
23
+ * The shared library of named particle effects: fire, smoke, sparks, dust, splashes, muzzle flash,
24
+ * explosions, magic and weather. Each entry is authored parameters only — rate, lifetime, size,
25
+ * colour and the sprite texture — so a game calls `ctx.particles.play("explosion/core", at)` and
26
+ * the editor authors the same name instead of hand-tuning an `EmitterConfig`. Names are the
27
+ * contract; a config retune here changes every consumer deliberately.
28
+ */
29
+ export const EFFECT_LIBRARY = [
30
+ // Fire — additive flame tongues that rise, shrink and cool from yellow to deep red.
31
+ {
32
+ name: "fire/flame", title: "Flame", group: "fire", priority: EFFECT_PRIORITY.fire, mode: "continuous", burst: 48, texture: "flame", blending: "additive",
33
+ config: { rate: 180, max: 256, direction: [0, 1, 0], spread: 0.5, speed: { min: 2.2, max: 3.8 }, gravity: [0, 1.5, 0], drag: 0.4, lifetime: { min: 0.7, max: 1.3 }, size: { start: 1.6, end: 0.1 }, colorStart: 0xffd24a, colorEnd: 0xff2a00, alpha: { start: 1, end: 0 } },
34
+ },
35
+ {
36
+ name: "fire/torch", title: "Torch", group: "fire", priority: EFFECT_PRIORITY.fire, mode: "continuous", burst: 32, texture: "flame", blending: "additive",
37
+ config: { rate: 60, max: 96, direction: [0, 1, 0], spread: 0.35, speed: { min: 1, max: 1.8 }, gravity: [0, 1.2, 0], drag: 0.5, lifetime: { min: 0.45, max: 0.8 }, size: { start: 0.5, end: 0.05 }, colorStart: 0xffe08a, colorEnd: 0xff5a1a, alpha: { start: 1, end: 0 } },
38
+ },
39
+ {
40
+ name: "fire/ember", title: "Embers", group: "fire", priority: EFFECT_PRIORITY.fire, mode: "burst", burst: 24, texture: "spark", stretch: 0.1, blending: "additive",
41
+ config: { max: 128, direction: [0, 1, 0], spread: 0.9, speed: { min: 1.2, max: 3 }, gravity: [0, 0.4, 0], drag: 0.3, lifetime: { min: 1.4, max: 2.6 }, size: { start: 0.16, end: 0.04 }, colorStart: 0xffc24a, colorEnd: 0xff3a00, alpha: { start: 1, end: 0 } },
42
+ },
43
+ // Smoke — normal-blended grey that grows as it fades.
44
+ {
45
+ name: "smoke/plume", title: "Smoke plume", group: "smoke", priority: EFFECT_PRIORITY.smoke, mode: "continuous", burst: 24, texture: "smoke", blending: "normal",
46
+ config: { rate: 36, max: 192, direction: [0.35, 1, 0], spread: 0.35, speed: { min: 0.8, max: 1.4 }, gravity: [0.2, 0.3, 0], drag: 0.6, lifetime: { min: 2.5, max: 4 }, size: { start: 1.2, end: 4 }, colorStart: 0xb2b8be, colorEnd: 0x40454a, alpha: { start: 0.5, end: 0 } },
47
+ },
48
+ {
49
+ name: "smoke/puff", title: "Smoke puff", group: "smoke", priority: EFFECT_PRIORITY.smoke, mode: "burst", burst: 12, texture: "smoke", blending: "normal",
50
+ config: { max: 64, direction: [0, 1, 0], spread: 0.7, speed: { min: 0.4, max: 1.1 }, drag: 0.8, lifetime: { min: 0.8, max: 1.6 }, size: { start: 0.6, end: 2.4 }, colorStart: 0x9aa0a6, colorEnd: 0x2f3438, alpha: { start: 0.45, end: 0 } },
51
+ },
52
+ // Sparks — additive streaks under strong gravity.
53
+ {
54
+ name: "sparks/weld", title: "Weld sparks", group: "sparks", priority: EFFECT_PRIORITY.sparks, mode: "burst", burst: 20, texture: "spark", stretch: 0.12, blending: "additive",
55
+ config: { max: 128, direction: [0, 1, 0], spread: 1.1, speed: { min: 4, max: 8 }, gravity: [0, -14, 0], lifetime: { min: 0.4, max: 0.9 }, size: { start: 0.22, end: 0.04 }, colorStart: 0xfff6d0, colorEnd: 0xff7a2a, alpha: { start: 1, end: 0 }, collision: { planeY: 0, response: "kill", maxEvents: 16 }, deathEvents: true },
56
+ },
57
+ {
58
+ name: "sparks/fountain", title: "Spark fountain", group: "sparks", priority: EFFECT_PRIORITY.sparks, mode: "continuous", burst: 32, texture: "spark", stretch: 0.1, blending: "additive",
59
+ config: { rate: 200, max: 384, direction: [0, 1, 0], spread: 0.85, speed: { min: 5, max: 8 }, gravity: [0, -12, 0], lifetime: { min: 0.9, max: 1.6 }, size: { start: 0.5, end: 0.08 }, colorStart: 0xfff2b0, colorEnd: 0xff8a3a, alpha: { start: 1, end: 0 } },
60
+ },
61
+ {
62
+ name: "sparks/ricochet", title: "Ricochet", group: "sparks", priority: EFFECT_PRIORITY.sparks, mode: "burst", burst: 10, texture: "spark", stretch: 0.14, blending: "additive",
63
+ config: { max: 48, direction: [0, 1, 0], spread: 1.4, speed: { min: 6, max: 11 }, gravity: [0, -16, 0], lifetime: { min: 0.2, max: 0.5 }, size: { start: 0.18, end: 0.03 }, colorStart: 0xffffff, colorEnd: 0xffb347, alpha: { start: 1, end: 0 } },
64
+ },
65
+ // Dust — normal-blended, low and wide.
66
+ {
67
+ name: "dust/impact", title: "Impact dust", group: "dust", priority: EFFECT_PRIORITY.dust, mode: "burst", burst: 16, texture: "soft", blending: "normal",
68
+ config: { max: 96, direction: [0, 1, 0], spread: 1.5, speed: { min: 0.5, max: 1.8 }, gravity: [0, -1.2, 0], drag: 0.9, lifetime: { min: 0.5, max: 1.1 }, size: { start: 0.4, end: 1.6 }, colorStart: 0xc9c0ac, colorEnd: 0x8a8071, alpha: { start: 0.55, end: 0 } },
69
+ },
70
+ {
71
+ name: "dust/plume", title: "Dust plume", group: "dust", priority: EFFECT_PRIORITY.dust, mode: "continuous", burst: 24, texture: "soft", blending: "normal",
72
+ config: { rate: 30, max: 160, direction: [0.2, 1, 0], spread: 0.5, speed: { min: 0.4, max: 1.1 }, drag: 0.7, lifetime: { min: 1.6, max: 3 }, size: { start: 0.8, end: 3.2 }, colorStart: 0xbdb3a0, colorEnd: 0x6f6555, alpha: { start: 0.4, end: 0 } },
73
+ },
74
+ {
75
+ name: "dust/motes", title: "Air motes", group: "dust", priority: EFFECT_PRIORITY.dust, mode: "continuous", burst: 12, texture: "soft", blending: "normal",
76
+ config: { rate: 14, max: 80, direction: [0, 1, 0], spread: Math.PI, speed: { min: 0.05, max: 0.25 }, drag: 0.2, lifetime: { min: 3, max: 6 }, size: { start: 0.06, end: 0.03 }, colorStart: 0xfff4dc, colorEnd: 0xe8dcc0, alpha: { start: 0.5, end: 0 } },
77
+ },
78
+ // Splashes — a quick upward crown of droplets that falls back.
79
+ {
80
+ name: "splash/impact", title: "Water splash", group: "splash", priority: EFFECT_PRIORITY.splash, mode: "burst", burst: 22, texture: "soft", blending: "normal",
81
+ config: { max: 96, direction: [0, 1, 0], spread: 1.1, speed: { min: 2.2, max: 4.5 }, gravity: [0, -9.8, 0], lifetime: { min: 0.35, max: 0.8 }, size: { start: 0.22, end: 0.06 }, colorStart: 0xdff1ff, colorEnd: 0x9fc4de, alpha: { start: 0.85, end: 0 } },
82
+ },
83
+ {
84
+ name: "splash/rain", title: "Rain impact", group: "splash", priority: EFFECT_PRIORITY.splash, mode: "burst", burst: 8, texture: "soft", blending: "normal",
85
+ config: { max: 48, direction: [0, 1, 0], spread: 0.9, speed: { min: 0.6, max: 1.4 }, gravity: [0, -9.8, 0], lifetime: { min: 0.2, max: 0.45 }, size: { start: 0.12, end: 0.03 }, colorStart: 0xd7ecff, colorEnd: 0x9ab6cc, alpha: { start: 0.7, end: 0 } },
86
+ },
87
+ // Muzzle flash — a very short, bright cone from the barrel, plus its smoke.
88
+ {
89
+ name: "muzzle/flash", title: "Muzzle flash", group: "muzzle", priority: EFFECT_PRIORITY.muzzle, mode: "burst", burst: 14, texture: "flame", blending: "additive",
90
+ config: { max: 48, direction: [0, 0, 1], spread: 0.35, speed: { min: 3, max: 7 }, lifetime: { min: 0.05, max: 0.12 }, size: { start: 0.7, end: 0.1 }, colorStart: 0xfff6c0, colorEnd: 0xff9a2a, alpha: { start: 1, end: 0 } },
91
+ },
92
+ {
93
+ name: "muzzle/smoke", title: "Muzzle smoke", group: "muzzle", priority: EFFECT_PRIORITY.muzzle, mode: "burst", burst: 8, texture: "smoke", blending: "normal",
94
+ config: { max: 32, direction: [0, 0.2, 1], spread: 0.5, speed: { min: 0.6, max: 1.6 }, drag: 0.9, lifetime: { min: 0.4, max: 0.9 }, size: { start: 0.25, end: 1 }, colorStart: 0xb0b4b8, colorEnd: 0x4a4e52, alpha: { start: 0.4, end: 0 } },
95
+ },
96
+ // Explosions — a fireball core, thrown debris and a ground shockwave ring.
97
+ {
98
+ name: "explosion/core", title: "Explosion", group: "explosion", priority: EFFECT_PRIORITY.explosion, mode: "burst", burst: 40, texture: "flame", blending: "additive",
99
+ config: { max: 160, direction: [0, 1, 0], spread: Math.PI, speed: { min: 3, max: 9 }, gravity: [0, 2, 0], drag: 1.2, lifetime: { min: 0.35, max: 0.8 }, size: { start: 1.8, end: 0.3 }, colorStart: 0xfff0b0, colorEnd: 0xff3a00, alpha: { start: 1, end: 0 } },
100
+ },
101
+ {
102
+ name: "explosion/debris", title: "Explosion debris", group: "explosion", priority: EFFECT_PRIORITY.explosion, mode: "burst", burst: 20, texture: "soft", blending: "normal",
103
+ config: { max: 96, direction: [0, 1, 0], spread: 1.3, speed: { min: 4, max: 10 }, gravity: [0, -16, 0], lifetime: { min: 0.6, max: 1.4 }, size: { start: 0.2, end: 0.06 }, colorStart: 0x6b6156, colorEnd: 0x3a342e, alpha: { start: 1, end: 0 }, collision: { planeY: 0, response: "bounce", restitution: 0.3, maxEvents: 24 }, deathEvents: true },
104
+ },
105
+ {
106
+ name: "explosion/shockwave", title: "Shockwave", group: "explosion", priority: EFFECT_PRIORITY.explosion, mode: "burst", burst: 6, texture: "ring", blending: "additive",
107
+ config: { max: 24, direction: [1, 0, 0], spread: Math.PI, speed: { min: 6, max: 9 }, drag: 1.4, lifetime: { min: 0.25, max: 0.45 }, size: { start: 1, end: 3.4 }, colorStart: 0xffe6b0, colorEnd: 0xff8a3a, alpha: { start: 0.7, end: 0 } },
108
+ },
109
+ // Magic — slow, floaty and bright.
110
+ {
111
+ name: "magic/cast", title: "Cast glow", group: "magic", priority: EFFECT_PRIORITY.magic, mode: "continuous", burst: 24, texture: "soft", blending: "additive",
112
+ config: { rate: 50, max: 128, direction: [0, 1, 0], spread: Math.PI, speed: { min: 0.3, max: 1 }, drag: 0.8, lifetime: { min: 0.6, max: 1.4 }, size: { start: 0.5, end: 0.1 }, colorStart: 0xd98aff, colorEnd: 0x6a2ad0, alpha: { start: 0.9, end: 0 } },
113
+ },
114
+ {
115
+ name: "magic/sparkle", title: "Sparkles", group: "magic", priority: EFFECT_PRIORITY.magic, mode: "burst", burst: 18, texture: "flake", blending: "additive",
116
+ config: { max: 96, direction: [0, 1, 0], spread: Math.PI, speed: { min: 0.8, max: 2.4 }, drag: 0.6, lifetime: { min: 0.8, max: 1.8 }, size: { start: 0.18, end: 0.02 }, colorStart: 0xffffff, colorEnd: 0xbfe6ff, alpha: { start: 1, end: 0 } },
117
+ },
118
+ {
119
+ name: "magic/heal", title: "Healing aura", group: "magic", priority: EFFECT_PRIORITY.magic, mode: "continuous", burst: 20, texture: "flake", blending: "additive",
120
+ config: { rate: 34, max: 96, direction: [0, 1, 0], spread: 0.5, speed: { min: 0.6, max: 1.4 }, drag: 0.5, lifetime: { min: 1, max: 2 }, size: { start: 0.16, end: 0.03 }, colorStart: 0xd8ffc0, colorEnd: 0x4ec46a, alpha: { start: 0.9, end: 0 } },
121
+ },
122
+ // Weather — ambient dressing that yields the budget first.
123
+ {
124
+ name: "weather/rain-splash", title: "Rain splashes", group: "weather", priority: EFFECT_PRIORITY.weather, mode: "burst", burst: 10, texture: "soft", blending: "normal",
125
+ config: { max: 64, direction: [0, 1, 0], spread: 1.2, speed: { min: 0.8, max: 1.8 }, gravity: [0, -9.8, 0], lifetime: { min: 0.18, max: 0.4 }, size: { start: 0.1, end: 0.02 }, colorStart: 0xcfe4f5, colorEnd: 0x8fa9bd, alpha: { start: 0.6, end: 0 } },
126
+ },
127
+ {
128
+ name: "weather/snow-puff", title: "Snow puffs", group: "weather", priority: EFFECT_PRIORITY.weather, mode: "burst", burst: 12, texture: "flake", blending: "normal",
129
+ config: { max: 64, direction: [0, 1, 0], spread: 1.2, speed: { min: 0.3, max: 0.9 }, gravity: [0, -0.6, 0], drag: 0.7, lifetime: { min: 0.6, max: 1.4 }, size: { start: 0.14, end: 0.04 }, colorStart: 0xffffff, colorEnd: 0xcfe0f0, alpha: { start: 0.9, end: 0 } },
130
+ },
131
+ {
132
+ name: "weather/leaves", title: "Drifting leaves", group: "weather", priority: EFFECT_PRIORITY.weather, mode: "continuous", burst: 10, texture: "flake", blending: "normal",
133
+ config: { rate: 8, max: 48, direction: [1, 0.2, 0], spread: 0.9, speed: { min: 0.4, max: 1.2 }, gravity: [0, -0.9, 0], drag: 0.9, lifetime: { min: 3, max: 6 }, size: { start: 0.2, end: 0.16 }, colorStart: 0xc7a45a, colorEnd: 0x7d5a2a, alpha: { start: 0.9, end: 0 } },
134
+ },
135
+ ];
136
+ const byName = new Map(EFFECT_LIBRARY.map((effect) => [effect.name, effect]));
137
+ /** The fallback effect used when a play call names an unknown effect — a typo still shows a spark. */
138
+ export const DEFAULT_EFFECT = "sparks/weld";
139
+ /** Every library effect name, for editor pickers and runtime discovery. */
140
+ export function effectNames() {
141
+ return EFFECT_LIBRARY.map((effect) => effect.name);
142
+ }
143
+ /** The named effect, or `undefined` when the name is not in the library.
144
+ * @capability find-effect look up one named particle effect in the shared library, or undefined when unknown
145
+ */
146
+ export function findEffect(name) {
147
+ return byName.get(name);
148
+ }
149
+ /**
150
+ * Resolve a named effect into concrete emitter data, with any `override` winning field by field.
151
+ * An unknown name resolves to {@link DEFAULT_EFFECT} rather than throwing, so a typo shows
152
+ * *something*; call {@link findEffect} when the caller must reject an unknown name.
153
+ *
154
+ * @capability effect-resolve turn a named library effect into emitter config, GPU sprite texture, blending and budget priority with per-call overrides
155
+ */
156
+ export function resolveEffect(name, override) {
157
+ const effect = byName.get(name) ?? byName.get(DEFAULT_EFFECT);
158
+ const { priority: overridePriority, render: overrideRender, ...optionOverrides } = override?.options ?? {};
159
+ const render = overrideRender ?? { shape: TEXTURE_SHAPE[effect.texture], ...(effect.stretch === undefined ? {} : { stretch: effect.stretch }) };
160
+ return {
161
+ name: effect.name,
162
+ group: effect.group,
163
+ mode: effect.mode,
164
+ priority: overridePriority ?? effect.priority,
165
+ burst: override?.burst ?? effect.burst,
166
+ config: {
167
+ ...effect.config,
168
+ ...override?.config,
169
+ ...(override?.position === undefined ? {} : { position: override.position }),
170
+ },
171
+ render,
172
+ blending: effect.blending,
173
+ options: {
174
+ ...effect.options,
175
+ ...optionOverrides,
176
+ blending: effect.blending,
177
+ priority: overridePriority ?? effect.priority,
178
+ render,
179
+ },
180
+ };
181
+ }