@jgengine/core 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +140 -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 +55 -4
  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 +12 -2
  45. package/dist/gameplay.js +10 -0
  46. package/dist/meta/changelog.d.ts +1 -1
  47. package/dist/meta/changelog.js +130 -1
  48. package/dist/mission/compositions.d.ts +85 -0
  49. package/dist/mission/compositions.js +130 -0
  50. package/dist/mission/mission.d.ts +435 -0
  51. package/dist/mission/mission.js +773 -0
  52. package/dist/mission/triggers.d.ts +61 -0
  53. package/dist/mission/triggers.js +108 -0
  54. package/dist/nav/traffic.d.ts +0 -1
  55. package/dist/nav/traffic.js +1 -2
  56. package/dist/runtime/context/combat.js +11 -0
  57. package/dist/settings/accessibilitySettings.d.ts +81 -0
  58. package/dist/settings/accessibilitySettings.js +152 -0
  59. package/dist/settings/graphicsProfile.d.ts +3 -0
  60. package/dist/settings/graphicsProfile.js +4 -0
  61. package/dist/settings/settingsModel.d.ts +1 -1
  62. package/dist/settings/settingsModel.js +1 -0
  63. package/dist/sim/nearAgents.d.ts +8 -1
  64. package/dist/sim/nearAgents.js +34 -17
  65. package/dist/sim/offScreen.d.ts +2 -1
  66. package/dist/sim/offScreen.js +14 -3
  67. package/dist/sim/population.d.ts +147 -0
  68. package/dist/sim/population.js +182 -0
  69. package/dist/sim/queue.d.ts +90 -0
  70. package/dist/sim/queue.js +171 -0
  71. package/dist/sim/rides.d.ts +287 -0
  72. package/dist/sim/rides.js +471 -0
  73. package/dist/sim/routine.js +19 -11
  74. package/dist/ui/loadingTips.d.ts +41 -0
  75. package/dist/ui/loadingTips.js +64 -0
  76. package/dist/ui/menuNav.d.ts +79 -0
  77. package/dist/ui/menuNav.js +147 -0
  78. package/dist/ui/menuTransition.d.ts +50 -0
  79. package/dist/ui/menuTransition.js +67 -0
  80. package/dist/ui.d.ts +2 -0
  81. package/dist/ui.js +2 -0
  82. package/dist/vfx/effectLibrary.d.ts +98 -0
  83. package/dist/vfx/effectLibrary.js +181 -0
  84. package/dist/vfx/particleBudget.d.ts +40 -0
  85. package/dist/vfx/particleBudget.js +39 -0
  86. package/dist/vfx/particleDirector.d.ts +16 -0
  87. package/dist/vfx/particleDirector.js +18 -0
  88. package/dist/world/authoredSimulation.js +7 -1
  89. package/dist/world/captureCamera.d.ts +30 -0
  90. package/dist/world/captureCamera.js +19 -0
  91. package/dist/world/features.d.ts +15 -4
  92. package/dist/world/terrain.d.ts +1 -0
  93. package/dist/world/terrain.js +2 -0
  94. package/dist/world.d.ts +2 -1
  95. package/dist/world.js +1 -0
  96. package/package.json +1 -1
  97. package/skills/jgengine/api.md +2 -2
  98. package/skills/jgengine-combat/SKILL.md +6 -0
  99. package/skills/jgengine-combat/api.md +5 -4
  100. package/skills/jgengine-combat/reference.md +4 -0
  101. package/skills/jgengine-gameplay/SKILL.md +55 -0
  102. package/skills/jgengine-gameplay/api.md +335 -10
  103. package/skills/jgengine-gameplay/capabilities.md +83 -1
  104. package/skills/jgengine-world/SKILL.md +4 -0
  105. package/skills/jgengine-world/api.md +128 -6
  106. package/skills/jgengine-world/capabilities.md +41 -1
  107. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  108. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Missions driven from authored scene markers. A designer places a marker (or volume) in the editor
3
+ * and sets one of the registered mission actions in the inspector — `mission_start`,
4
+ * `mission_objective` or `mission_fail` — and the runtime starts, advances or fails the mission when
5
+ * an actor trips it. Nothing about the marker is mission-specific code; it is the shared authored
6
+ * trigger vocabulary, plus the marker positions a positional objective reads.
7
+ */
8
+ import { type AuthoredTrigger, type TriggerActionDefinition, type TriggerDispatchEvent } from "../scene/authoredTriggers.js";
9
+ import type { SceneDocumentLike, ScenePoint3 } from "../world/sceneShapes.js";
10
+ import type { MissionRuntime } from "./mission.js";
11
+ /**
12
+ * Marker positions from an authored scene, keyed by marker id — what a positional objective reads.
13
+ *
14
+ * @capability mission-triggers read authored scene marker positions for positional objectives
15
+ */
16
+ export declare function missionMarkers(document: SceneDocumentLike): Record<string, ScenePoint3>;
17
+ /**
18
+ * The mission actions a game registers so the editor inspector can assign them to a marker or volume.
19
+ * Params land flat on the object's `meta` beside `on`/`action`, like any authored trigger.
20
+ *
21
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
22
+ */
23
+ export declare const MISSION_TRIGGER_ACTIONS: readonly TriggerActionDefinition[];
24
+ /**
25
+ * Register {@link MISSION_TRIGGER_ACTIONS} so the editor lists them. Idempotent; call at module load
26
+ * next to the game's catalogs.
27
+ *
28
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
29
+ */
30
+ export declare function registerMissionTriggerActions(): void;
31
+ /** Watches a document's authored mission triggers against moving actors. */
32
+ export interface MissionTriggerBridge {
33
+ /** Advance one tick; fires the mission actions the actors tripped. */
34
+ step(input: {
35
+ actors: readonly {
36
+ id: string;
37
+ position: readonly [number, number, number] | {
38
+ x: number;
39
+ y: number;
40
+ z: number;
41
+ };
42
+ }[];
43
+ interact?: readonly string[];
44
+ }): readonly TriggerDispatchEvent[];
45
+ /** The mission triggers this bridge was built from. */
46
+ triggers(): readonly AuthoredTrigger[];
47
+ /** Drop membership so the next step re-fires enters for still-inside actors. */
48
+ reset(): void;
49
+ }
50
+ /**
51
+ * Wire authored markers to a {@link MissionRuntime}: an actor entering a `mission_start` marker starts
52
+ * the named mission, `mission_objective` completes the named objective, and `mission_fail` lands the
53
+ * named fail state. Pure membership math over the document, exactly like any other authored trigger.
54
+ *
55
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
56
+ */
57
+ export declare function createMissionTriggerBridge(options: {
58
+ runtime: MissionRuntime;
59
+ document: SceneDocumentLike;
60
+ triggers?: readonly AuthoredTrigger[];
61
+ }): MissionTriggerBridge;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Missions driven from authored scene markers. A designer places a marker (or volume) in the editor
3
+ * and sets one of the registered mission actions in the inspector — `mission_start`,
4
+ * `mission_objective` or `mission_fail` — and the runtime starts, advances or fails the mission when
5
+ * an actor trips it. Nothing about the marker is mission-specific code; it is the shared authored
6
+ * trigger vocabulary, plus the marker positions a positional objective reads.
7
+ */
8
+ import { createAuthoredTriggerRuntime, registerTriggerAction, } from "../scene/authoredTriggers.js";
9
+ /**
10
+ * Marker positions from an authored scene, keyed by marker id — what a positional objective reads.
11
+ *
12
+ * @capability mission-triggers read authored scene marker positions for positional objectives
13
+ */
14
+ export function missionMarkers(document) {
15
+ const out = {};
16
+ for (const marker of document.markers) {
17
+ out[marker.id] = { x: marker.position.x, y: marker.position.y, z: marker.position.z };
18
+ }
19
+ return out;
20
+ }
21
+ /**
22
+ * The mission actions a game registers so the editor inspector can assign them to a marker or volume.
23
+ * Params land flat on the object's `meta` beside `on`/`action`, like any authored trigger.
24
+ *
25
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
26
+ */
27
+ export const MISSION_TRIGGER_ACTIONS = [
28
+ {
29
+ id: "mission_start",
30
+ label: "Start mission",
31
+ schema: { fields: [{ type: "text", key: "mission", label: "Mission", default: "" }] },
32
+ events: ["enter"],
33
+ },
34
+ {
35
+ id: "mission_objective",
36
+ label: "Complete mission objective",
37
+ schema: {
38
+ fields: [
39
+ { type: "text", key: "mission", label: "Mission", default: "" },
40
+ { type: "text", key: "objective", label: "Objective", default: "" },
41
+ ],
42
+ },
43
+ events: ["enter", "interact"],
44
+ },
45
+ {
46
+ id: "mission_fail",
47
+ label: "Fail mission",
48
+ schema: {
49
+ fields: [
50
+ { type: "text", key: "mission", label: "Mission", default: "" },
51
+ { type: "text", key: "state", label: "Fail state", default: "" },
52
+ ],
53
+ },
54
+ events: ["enter", "interact"],
55
+ },
56
+ ];
57
+ /**
58
+ * Register {@link MISSION_TRIGGER_ACTIONS} so the editor lists them. Idempotent; call at module load
59
+ * next to the game's catalogs.
60
+ *
61
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
62
+ */
63
+ export function registerMissionTriggerActions() {
64
+ for (const action of MISSION_TRIGGER_ACTIONS)
65
+ registerTriggerAction(action);
66
+ }
67
+ function textParam(event, key) {
68
+ const value = event.params[key];
69
+ return typeof value === "string" && value.length > 0 ? value : undefined;
70
+ }
71
+ /**
72
+ * Wire authored markers to a {@link MissionRuntime}: an actor entering a `mission_start` marker starts
73
+ * the named mission, `mission_objective` completes the named objective, and `mission_fail` lands the
74
+ * named fail state. Pure membership math over the document, exactly like any other authored trigger.
75
+ *
76
+ * @capability mission-triggers start, advance and fail a mission from authored scene markers
77
+ */
78
+ export function createMissionTriggerBridge(options) {
79
+ const handlers = {
80
+ mission_start: (event) => {
81
+ const mission = textParam(event, "mission");
82
+ if (mission !== undefined)
83
+ options.runtime.start(mission);
84
+ },
85
+ mission_objective: (event) => {
86
+ const mission = textParam(event, "mission");
87
+ const objective = textParam(event, "objective");
88
+ if (mission !== undefined && objective !== undefined)
89
+ options.runtime.signal(mission, objective);
90
+ },
91
+ mission_fail: (event) => {
92
+ const mission = textParam(event, "mission");
93
+ const state = textParam(event, "state");
94
+ if (mission !== undefined && state !== undefined)
95
+ options.runtime.fail(mission, state);
96
+ },
97
+ };
98
+ const runtime = createAuthoredTriggerRuntime({
99
+ document: options.document,
100
+ handlers,
101
+ triggers: options.triggers,
102
+ });
103
+ return {
104
+ step: (input) => runtime.step(input),
105
+ triggers: () => runtime.triggers(),
106
+ reset: () => runtime.reset(),
107
+ };
108
+ }
@@ -151,7 +151,6 @@ export interface TrafficSnapshot {
151
151
  ticks: number;
152
152
  maxStillSeconds: number;
153
153
  maxDeadlockSeconds: number;
154
- slowestTickMs: number;
155
154
  };
156
155
  }
157
156
  /** Everything {@link createTraffic} needs: the graph, the injected generator and the caller's policies. */
@@ -861,7 +861,6 @@ export function createTraffic(options) {
861
861
  ticks,
862
862
  maxStillSeconds,
863
863
  maxDeadlockSeconds,
864
- slowestTickMs,
865
864
  },
866
865
  };
867
866
  },
@@ -883,7 +882,7 @@ export function createTraffic(options) {
883
882
  ticks = next.counters.ticks;
884
883
  maxStillSeconds = next.counters.maxStillSeconds;
885
884
  maxDeadlockSeconds = next.counters.maxDeadlockSeconds;
886
- slowestTickMs = next.counters.slowestTickMs;
885
+ slowestTickMs = 0;
887
886
  for (const saved of next.cars) {
888
887
  const car = {
889
888
  ...saved,
@@ -59,6 +59,17 @@ export function createCombatSubsystem(d) {
59
59
  const userId = userIdOf(instanceId);
60
60
  return { catalogId: entity.name, name: entity.name, ...(userId === undefined ? {} : { userId }) };
61
61
  },
62
+ onDamage(application) {
63
+ events.emit("combat.damage", {
64
+ target: application.target,
65
+ source: application.source,
66
+ type: application.effect,
67
+ amount: application.dealt,
68
+ absorbed: Math.max(0, application.incoming - application.dealt),
69
+ remaining: application.remaining,
70
+ lethal: application.lethal,
71
+ });
72
+ },
62
73
  onLethal(instanceId, lethalCtx) {
63
74
  // Capture identity + onDeath *before* resolveDeath despawns the entity.
64
75
  const dyingEntity = entities.get(instanceId);
@@ -0,0 +1,81 @@
1
+ import type { AccessibilityState, ColorblindMode } from "../ui/accessibility.js";
2
+ import type { GameSettingDef, SettingOption, SettingValue, SettingsStore } from "./settingsModel.js";
3
+ /**
4
+ * A "hold or toggle" preference for actions that can be either (aim, sprint, crouch, block). Shared
5
+ * because players expect one setting to cover them all, rather than each game inventing a flag.
6
+ */
7
+ export type HoldVsToggle = "hold" | "toggle";
8
+ /** The persisted settings ids behind the accessibility section. */
9
+ export declare const ACCESSIBILITY_SETTING_IDS: {
10
+ readonly textScale: "accessibility.textScale";
11
+ readonly colorblind: "accessibility.colorblind";
12
+ readonly reducedMotion: "accessibility.reducedMotion";
13
+ readonly highContrast: "accessibility.highContrast";
14
+ readonly captions: "accessibility.captions";
15
+ readonly holdVsToggle: "controls.holdVsToggle";
16
+ };
17
+ /** Text-scale choices, as numeric strings the settings store persists. */
18
+ export declare const TEXT_SCALE_OPTIONS: readonly SettingOption[];
19
+ /** Colorblind palette choices. `none` keeps the game's own colours. */
20
+ export declare const COLORBLIND_OPTIONS: readonly SettingOption[];
21
+ /** Hold-versus-toggle choices for actions that support both. */
22
+ export declare const HOLD_VS_TOGGLE_OPTIONS: readonly SettingOption[];
23
+ /** Read a stored text scale, clamped to the supported range. */
24
+ export declare function readTextScale(store: SettingsStore): number;
25
+ /** Read a stored colorblind mode, falling back to `none` for an unknown value. */
26
+ export declare function readColorblind(store: SettingsStore): ColorblindMode;
27
+ /** Read the hold-or-toggle preference, falling back to `hold`. */
28
+ export declare function readHoldVsToggle(store: SettingsStore): HoldVsToggle;
29
+ /**
30
+ * Read the whole accessibility state the presentation needs (reduced motion, high contrast, text
31
+ * scale, colorblind mode, captions) plus the hold/toggle preference, from the shared settings store.
32
+ *
33
+ * @capability accessibility-settings read accessibility preferences and the hold-vs-toggle choice from the shared settings store
34
+ */
35
+ export declare function readAccessibilitySettings(store: SettingsStore): {
36
+ state: AccessibilityState;
37
+ holdVsToggle: HoldVsToggle;
38
+ };
39
+ /** One accessibility setting as the shared settings menu renders it. */
40
+ export interface AccessibilitySettingRow {
41
+ id: string;
42
+ label: string;
43
+ kind: "slider" | "toggle" | "select";
44
+ value: SettingValue;
45
+ min?: number;
46
+ max?: number;
47
+ step?: number;
48
+ options?: readonly SettingOption[];
49
+ format?: (value: number) => string;
50
+ set: (value: SettingValue) => void;
51
+ /** When this row drives presentation, the accessibility state key it maps to. */
52
+ affects?: keyof AccessibilityState;
53
+ }
54
+ /**
55
+ * The accessibility rows, wired to the settings store: text scale, colorblind palette, reduced
56
+ * motion, high contrast, captions and hold-vs-toggle. The shell adds them as its `accessibility`
57
+ * category and any game can render them inside its own settings layout.
58
+ *
59
+ * @capability accessibility-settings-rows accessibility setting rows (text scale, colorblind, reduced motion, high contrast, captions, hold-vs-toggle) backed by the shared settings store
60
+ */
61
+ export declare function accessibilitySettingRows(store: SettingsStore): AccessibilitySettingRow[];
62
+ /**
63
+ * Declarative form of the accessibility rows for `defineGame({ settings })`, for a game that wants
64
+ * them inside its own setting categories rather than the built-in `accessibility` one.
65
+ */
66
+ export declare function accessibilitySettingDefs(category?: string): GameSettingDef[];
67
+ /** One frame of an action's raw input. */
68
+ export interface HoldToggleInput {
69
+ /** The action's key/button went down this frame (the edge). */
70
+ pressed: boolean;
71
+ /** The action's key/button is down. */
72
+ held: boolean;
73
+ }
74
+ /**
75
+ * The action's active state for one frame under a {@link HoldVsToggle} preference: `hold` follows the
76
+ * button, `toggle` latches on each press edge. Call with the previous result so toggle mode can flip
77
+ * without the caller keeping a second flag.
78
+ *
79
+ * @capability hold-vs-toggle resolve one action frame under a hold-or-toggle preference
80
+ */
81
+ export declare function holdToggleActive(previous: boolean, input: HoldToggleInput, mode: HoldVsToggle): boolean;
@@ -0,0 +1,152 @@
1
+ import { DEFAULT_ACCESSIBILITY, clampTextScale } from "../ui/accessibility.js";
2
+ /** The persisted settings ids behind the accessibility section. */
3
+ export const ACCESSIBILITY_SETTING_IDS = {
4
+ textScale: "accessibility.textScale",
5
+ colorblind: "accessibility.colorblind",
6
+ reducedMotion: "accessibility.reducedMotion",
7
+ highContrast: "accessibility.highContrast",
8
+ captions: "accessibility.captions",
9
+ holdVsToggle: "controls.holdVsToggle",
10
+ };
11
+ /** Text-scale choices, as numeric strings the settings store persists. */
12
+ export const TEXT_SCALE_OPTIONS = [
13
+ { value: "1", label: "100%" },
14
+ { value: "1.15", label: "115%" },
15
+ { value: "1.3", label: "130%" },
16
+ { value: "1.5", label: "150%" },
17
+ { value: "1.75", label: "175%" },
18
+ { value: "2", label: "200%" },
19
+ ];
20
+ /** Colorblind palette choices. `none` keeps the game's own colours. */
21
+ export const COLORBLIND_OPTIONS = [
22
+ { value: "none", label: "Off" },
23
+ { value: "protanopia", label: "Protanopia" },
24
+ { value: "deuteranopia", label: "Deuteranopia" },
25
+ { value: "tritanopia", label: "Tritanopia" },
26
+ { value: "grayscale", label: "Grayscale" },
27
+ ];
28
+ /** Hold-versus-toggle choices for actions that support both. */
29
+ export const HOLD_VS_TOGGLE_OPTIONS = [
30
+ { value: "hold", label: "Hold" },
31
+ { value: "toggle", label: "Toggle" },
32
+ ];
33
+ /** Read a stored text scale, clamped to the supported range. */
34
+ export function readTextScale(store) {
35
+ return clampTextScale(Number(store.get(ACCESSIBILITY_SETTING_IDS.textScale, 1)));
36
+ }
37
+ /** Read a stored colorblind mode, falling back to `none` for an unknown value. */
38
+ export function readColorblind(store) {
39
+ const value = String(store.get(ACCESSIBILITY_SETTING_IDS.colorblind, "none"));
40
+ const known = COLORBLIND_OPTIONS.some((option) => option.value === value);
41
+ return known ? value : "none";
42
+ }
43
+ /** Read the hold-or-toggle preference, falling back to `hold`. */
44
+ export function readHoldVsToggle(store) {
45
+ const value = String(store.get(ACCESSIBILITY_SETTING_IDS.holdVsToggle, "hold"));
46
+ return value === "toggle" ? "toggle" : "hold";
47
+ }
48
+ /**
49
+ * Read the whole accessibility state the presentation needs (reduced motion, high contrast, text
50
+ * scale, colorblind mode, captions) plus the hold/toggle preference, from the shared settings store.
51
+ *
52
+ * @capability accessibility-settings read accessibility preferences and the hold-vs-toggle choice from the shared settings store
53
+ */
54
+ export function readAccessibilitySettings(store) {
55
+ return {
56
+ state: {
57
+ reducedMotion: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.reducedMotion, DEFAULT_ACCESSIBILITY.reducedMotion)),
58
+ highContrast: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.highContrast, DEFAULT_ACCESSIBILITY.highContrast)),
59
+ textScale: readTextScale(store),
60
+ colorblind: readColorblind(store),
61
+ captions: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.captions, DEFAULT_ACCESSIBILITY.captions)),
62
+ },
63
+ holdVsToggle: readHoldVsToggle(store),
64
+ };
65
+ }
66
+ /**
67
+ * The accessibility rows, wired to the settings store: text scale, colorblind palette, reduced
68
+ * motion, high contrast, captions and hold-vs-toggle. The shell adds them as its `accessibility`
69
+ * category and any game can render them inside its own settings layout.
70
+ *
71
+ * @capability accessibility-settings-rows accessibility setting rows (text scale, colorblind, reduced motion, high contrast, captions, hold-vs-toggle) backed by the shared settings store
72
+ */
73
+ export function accessibilitySettingRows(store) {
74
+ return [
75
+ {
76
+ id: ACCESSIBILITY_SETTING_IDS.textScale,
77
+ label: "Text size",
78
+ kind: "select",
79
+ value: String(readTextScale(store)),
80
+ options: TEXT_SCALE_OPTIONS,
81
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.textScale, Number(value)),
82
+ affects: "textScale",
83
+ },
84
+ {
85
+ id: ACCESSIBILITY_SETTING_IDS.colorblind,
86
+ label: "Colourblind palette",
87
+ kind: "select",
88
+ value: readColorblind(store),
89
+ options: COLORBLIND_OPTIONS,
90
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.colorblind, String(value)),
91
+ affects: "colorblind",
92
+ },
93
+ {
94
+ id: ACCESSIBILITY_SETTING_IDS.reducedMotion,
95
+ label: "Reduce motion",
96
+ kind: "toggle",
97
+ value: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.reducedMotion, DEFAULT_ACCESSIBILITY.reducedMotion)),
98
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.reducedMotion, Boolean(value)),
99
+ affects: "reducedMotion",
100
+ },
101
+ {
102
+ id: ACCESSIBILITY_SETTING_IDS.highContrast,
103
+ label: "High contrast",
104
+ kind: "toggle",
105
+ value: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.highContrast, DEFAULT_ACCESSIBILITY.highContrast)),
106
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.highContrast, Boolean(value)),
107
+ affects: "highContrast",
108
+ },
109
+ {
110
+ id: ACCESSIBILITY_SETTING_IDS.captions,
111
+ label: "Captions",
112
+ kind: "toggle",
113
+ value: Boolean(store.get(ACCESSIBILITY_SETTING_IDS.captions, DEFAULT_ACCESSIBILITY.captions)),
114
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.captions, Boolean(value)),
115
+ affects: "captions",
116
+ },
117
+ {
118
+ id: ACCESSIBILITY_SETTING_IDS.holdVsToggle,
119
+ label: "Hold or toggle aim",
120
+ kind: "select",
121
+ value: readHoldVsToggle(store),
122
+ options: HOLD_VS_TOGGLE_OPTIONS,
123
+ set: (value) => store.set(ACCESSIBILITY_SETTING_IDS.holdVsToggle, String(value)),
124
+ },
125
+ ];
126
+ }
127
+ /**
128
+ * Declarative form of the accessibility rows for `defineGame({ settings })`, for a game that wants
129
+ * them inside its own setting categories rather than the built-in `accessibility` one.
130
+ */
131
+ export function accessibilitySettingDefs(category = "accessibility") {
132
+ return [
133
+ { id: ACCESSIBILITY_SETTING_IDS.textScale, label: "Text size", category, kind: "select", default: "1", options: TEXT_SCALE_OPTIONS },
134
+ { id: ACCESSIBILITY_SETTING_IDS.colorblind, label: "Colourblind palette", category, kind: "select", default: "none", options: COLORBLIND_OPTIONS },
135
+ { id: ACCESSIBILITY_SETTING_IDS.reducedMotion, label: "Reduce motion", category, kind: "toggle", default: false },
136
+ { id: ACCESSIBILITY_SETTING_IDS.highContrast, label: "High contrast", category, kind: "toggle", default: false },
137
+ { id: ACCESSIBILITY_SETTING_IDS.captions, label: "Captions", category, kind: "toggle", default: false },
138
+ { id: ACCESSIBILITY_SETTING_IDS.holdVsToggle, label: "Hold or toggle aim", category: "controls", kind: "select", default: "hold", options: HOLD_VS_TOGGLE_OPTIONS },
139
+ ];
140
+ }
141
+ /**
142
+ * The action's active state for one frame under a {@link HoldVsToggle} preference: `hold` follows the
143
+ * button, `toggle` latches on each press edge. Call with the previous result so toggle mode can flip
144
+ * without the caller keeping a second flag.
145
+ *
146
+ * @capability hold-vs-toggle resolve one action frame under a hold-or-toggle preference
147
+ */
148
+ export function holdToggleActive(previous, input, mode) {
149
+ if (mode === "hold")
150
+ return input.held;
151
+ return input.pressed ? !previous : previous;
152
+ }
@@ -10,7 +10,10 @@ export interface GraphicsProfile {
10
10
  shadowMapSize: 512 | 1024 | 2048 | 4096;
11
11
  cascades: 1 | 2 | 3 | 4;
12
12
  drawDistance: number;
13
+ /** Largest pool one particle effect may allocate. */
13
14
  particleCap: number;
15
+ /** Live particles the whole scene may hold across every effect; the VFX library drops lowest-priority effects past it. */
16
+ particleBudget: number;
14
17
  /** Anisotropic texture filtering samples; clamped to the GPU maximum. 1 is plain trilinear. */
15
18
  anisotropy: 1 | 2 | 4 | 8 | 16;
16
19
  postStages: {
@@ -6,6 +6,7 @@ export const DEFAULT_GRAPHICS_PROFILES = {
6
6
  cascades: 1,
7
7
  drawDistance: 120,
8
8
  particleCap: 128,
9
+ particleBudget: 1024,
9
10
  anisotropy: 2,
10
11
  postStages: { ao: false, bloom: true, dof: false, smaa: true },
11
12
  surfaceDetail: "lite",
@@ -16,6 +17,7 @@ export const DEFAULT_GRAPHICS_PROFILES = {
16
17
  cascades: 2,
17
18
  drawDistance: 240,
18
19
  particleCap: 256,
20
+ particleBudget: 2048,
19
21
  anisotropy: 4,
20
22
  postStages: { ao: true, bloom: true, dof: true, smaa: true },
21
23
  surfaceDetail: "full",
@@ -26,6 +28,7 @@ export const DEFAULT_GRAPHICS_PROFILES = {
26
28
  cascades: 4,
27
29
  drawDistance: 400,
28
30
  particleCap: 512,
31
+ particleBudget: 4096,
29
32
  anisotropy: 8,
30
33
  postStages: { ao: true, bloom: true, dof: true, smaa: true },
31
34
  surfaceDetail: "full",
@@ -36,6 +39,7 @@ export const DEFAULT_GRAPHICS_PROFILES = {
36
39
  cascades: 4,
37
40
  drawDistance: 800,
38
41
  particleCap: 1024,
42
+ particleBudget: 8192,
39
43
  anisotropy: 16,
40
44
  postStages: { ao: true, bloom: true, dof: true, smaa: true },
41
45
  surfaceDetail: "full",
@@ -1,7 +1,7 @@
1
1
  import type { GameContext } from "../runtime/gameContext.js";
2
2
  export type SettingValue = number | boolean | string;
3
3
  export type SettingKind = "slider" | "toggle" | "select";
4
- export type BuiltInSettingCategory = "sound" | "graphics" | "gameplay" | "controls";
4
+ export type BuiltInSettingCategory = "sound" | "graphics" | "gameplay" | "controls" | "accessibility";
5
5
  /** Built-in category ids keep autocomplete; any other string makes a fresh category. */
6
6
  export type SettingCategory = BuiltInSettingCategory | (string & {});
7
7
  export declare const BUILT_IN_SETTING_CATEGORIES: readonly BuiltInSettingCategory[];
@@ -3,6 +3,7 @@ export const BUILT_IN_SETTING_CATEGORIES = [
3
3
  "sound",
4
4
  "graphics",
5
5
  "gameplay",
6
+ "accessibility",
6
7
  "controls",
7
8
  ];
8
9
  export const SETTINGS_STORAGE_PREFIX = "jgengine:setting:";
@@ -1,5 +1,5 @@
1
1
  import type { Vec3 } from "../world/geometry.js";
2
- import { type NavCrowd, type NavCrowdOptions } from "../nav/navCrowd.js";
2
+ import { type NavCrowd, type NavCrowdOptions, type NavCrowdSnapshot } from "../nav/navCrowd.js";
3
3
  import type { NavRuntime } from "../nav/navRuntime.js";
4
4
  import { type UtilityBrainSnapshot, type UtilityCandidate, type UtilityInterrupt, type UtilityOption } from "../ai/utilityBrain.js";
5
5
  import { type NeedDefinition, type NeedSnapshot } from "./needs.js";
@@ -51,11 +51,18 @@ export interface NearCrowdAgents extends NearAgents {
51
51
  decisions(): number;
52
52
  view(id: string): NearAgentView | null;
53
53
  crowd(): NavCrowd;
54
+ /**
55
+ * Serializable state: each agent's needs and held pick, plus the crowd's own routes. The crowd is
56
+ * part of the snapshot because a restored agent must resume the route it was walking, not re-plan a
57
+ * fresh one from wherever it stood.
58
+ */
54
59
  snapshot(): {
55
60
  agents: NearAgentSnapshot[];
61
+ crowd: NavCrowdSnapshot;
56
62
  };
57
63
  restore(next: {
58
64
  agents: readonly NearAgentSnapshot[];
65
+ crowd: NavCrowdSnapshot;
59
66
  }): void;
60
67
  }
61
68
  /**
@@ -33,16 +33,19 @@ export function createNearAgents(options) {
33
33
  needs: agent.needs.snapshot(),
34
34
  };
35
35
  }
36
+ function makeBrain(forNeeds) {
37
+ return createUtilityBrain({
38
+ needs: forNeeds,
39
+ options: options.options ?? [],
40
+ ...(options.interrupts === undefined ? {} : { interrupts: options.interrupts }),
41
+ });
42
+ }
36
43
  function mount(next, saved) {
37
44
  if (agents.has(next.id))
38
45
  return;
39
46
  const needs = createNeedSet(defs);
40
47
  needs.restore(saved === undefined ? { values: { ...next.needs }, rates: {}, modifiers: {} } : saved.needs);
41
- const brain = createUtilityBrain({
42
- needs,
43
- options: options.options ?? [],
44
- ...(options.interrupts === undefined ? {} : { interrupts: options.interrupts }),
45
- });
48
+ const brain = makeBrain(needs);
46
49
  if (saved !== undefined)
47
50
  brain.restore(saved.brain);
48
51
  const goal = saved?.goal ?? next.goal;
@@ -120,25 +123,39 @@ export function createNearAgents(options) {
120
123
  brain: agent.brain.snapshot(),
121
124
  });
122
125
  }
123
- return { agents: out };
126
+ return { agents: out, crowd: crowd.snapshot() };
124
127
  },
125
128
  restore(next) {
126
- for (const id of [...agents.keys()]) {
127
- crowd.remove(id);
128
- agents.get(id)?.brain.dispose();
129
- agents.delete(id);
130
- }
129
+ for (const agent of agents.values())
130
+ agent.brain.dispose();
131
+ agents.clear();
132
+ // The crowd's own snapshot carries each agent's route, so a restored agent walks the corridor it
133
+ // was on rather than a fresh plan from its current spot.
134
+ crowd.restore(next.crowd);
131
135
  for (const saved of next.agents) {
132
- mount({
136
+ const needs = createNeedSet(defs);
137
+ needs.restore(saved.needs);
138
+ const brain = makeBrain(needs);
139
+ brain.restore(saved.brain);
140
+ agents.set(saved.id, {
133
141
  id: saved.id,
134
- position: saved.position,
142
+ needs,
143
+ brain,
144
+ goal: saved.goal,
135
145
  place: saved.place,
136
146
  activity: saved.activity,
137
- needs: saved.needs.values,
138
- goal: saved.goal,
139
- stop: 0,
140
147
  travelling: saved.travelling,
141
- }, saved);
148
+ spawned: {
149
+ id: saved.id,
150
+ position: saved.position,
151
+ place: saved.place,
152
+ activity: saved.activity,
153
+ needs: saved.needs.values,
154
+ goal: saved.goal,
155
+ stop: 0,
156
+ travelling: saved.travelling,
157
+ },
158
+ });
142
159
  }
143
160
  },
144
161
  dispose() {
@@ -119,7 +119,8 @@ export interface OffScreenSnapshot {
119
119
  travelling: boolean;
120
120
  pinned: boolean;
121
121
  updatedAt: number;
122
- staleAt: number;
122
+ /** Next cheap-state refresh; `null` for a resident that never goes stale (no routine). */
123
+ staleAt: number | null;
123
124
  needs: Record<string, number>;
124
125
  }[];
125
126
  }
@@ -89,7 +89,18 @@ export function createOffScreenWorld(options) {
89
89
  }
90
90
  }
91
91
  }
92
- resident.staleAt = resident.routine.nextChangeAfter(resident.updatedAt);
92
+ const next = resident.routine.nextChangeAfter(resident.updatedAt);
93
+ if (next > resident.updatedAt) {
94
+ resident.staleAt = next;
95
+ }
96
+ else {
97
+ // A schedule that cannot name a later change must not spin this catch-up loop; hold the
98
+ // phase, decay to `now` and try once more a cycle later.
99
+ resident.needs = decayMeters(resident.needs, defs, now - resident.updatedAt);
100
+ resident.updatedAt = now;
101
+ resident.staleAt = now + resident.routine.cycleSeconds;
102
+ break;
103
+ }
93
104
  }
94
105
  }
95
106
  /** Keep a near resident's routine phase current and walk it to the next place when the clock moves it. */
@@ -380,7 +391,7 @@ export function createOffScreenWorld(options) {
380
391
  travelling: resident.travelling,
381
392
  pinned: resident.pinned,
382
393
  updatedAt: resident.updatedAt,
383
- staleAt: resident.staleAt,
394
+ staleAt: Number.isFinite(resident.staleAt) ? resident.staleAt : null,
384
395
  needs: { ...(live ?? resident.needs) },
385
396
  };
386
397
  });
@@ -418,7 +429,7 @@ export function createOffScreenWorld(options) {
418
429
  travelling: saved.travelling,
419
430
  pinned: saved.pinned,
420
431
  updatedAt: saved.updatedAt,
421
- staleAt: saved.staleAt,
432
+ staleAt: saved.staleAt === null ? Infinity : saved.staleAt,
422
433
  needs: { ...saved.needs },
423
434
  distance: Infinity,
424
435
  };