@jgengine/core 0.13.0 → 0.15.0-next.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 +529 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +21 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +48 -1
- package/dist/runtime/gameContext.js +8 -2
- package/dist/runtime/gameContextTypes.d.ts +39 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/builtinSceneKinds.d.ts +6 -1
- package/dist/scene/builtinSceneKinds.js +10 -3
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/colliders.d.ts +20 -0
- package/dist/scene/colliders.js +78 -0
- package/dist/scene/collisionMesh.d.ts +8 -0
- package/dist/scene/collisionMesh.js +48 -0
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +37 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/hudScale.d.ts +16 -1
- package/dist/ui/hudScale.js +22 -1
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +9 -46
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grassKind.js +4 -1
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/scatterCoverage.d.ts +98 -0
- package/dist/world/scatterCoverage.js +101 -0
- package/dist/world/scatterRegion.js +6 -0
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +18 -2
- package/dist/world.js +18 -2
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +408 -12
- package/skills/jgengine-world/capabilities.md +127 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless, serializable model for a stack of modal dialogs — the data layer behind a pause menu, a
|
|
3
|
+
* "quit to menu?" confirmation, a level-up popup, or any blocking overlay a game raises over its
|
|
4
|
+
* scene. It carries an ordered stack of opaque {@link ModalRecord}s (push to open, pop/resolve to
|
|
5
|
+
* close) and nothing about how they look: the model NEVER interprets a record's `kind` or a
|
|
6
|
+
* resolution's `result` string — those are free-form game vocabulary the React chrome in
|
|
7
|
+
* `@jgengine/react` renders. Observable ({@link ModalStack.subscribe}) and round-trips through a save
|
|
8
|
+
* ({@link ModalStack.snapshot}/{@link ModalStack.restore}). An injected clock powers optional
|
|
9
|
+
* per-modal auto-dismiss timers; leave those unset and the model is pure and clockless.
|
|
10
|
+
*/
|
|
11
|
+
/** Conventional confirm result for a two-button dialog — a shared string, not model behavior. */
|
|
12
|
+
export const MODAL_CONFIRM = "confirm";
|
|
13
|
+
/** Conventional cancel/dismiss result (also the default auto-dismiss and pop result). */
|
|
14
|
+
export const MODAL_CANCEL = "cancel";
|
|
15
|
+
/**
|
|
16
|
+
* A stack of modal dialogs: push opaque records to open blocking overlays (a pause menu, a confirm
|
|
17
|
+
* prompt, a reward popup), pop or resolve them with a free-form result to close, and query the top /
|
|
18
|
+
* open / depth to gate game input. The model is deliberately vocabulary-free — it never reads a
|
|
19
|
+
* record's `kind` or a resolution's `result`, so a game owns every dialog style — and fully
|
|
20
|
+
* serializable, with an optional injected clock driving per-modal auto-dismiss. A React host renders
|
|
21
|
+
* `top()`; `subscribe` re-renders it, `snapshot`/`restore` round-trip it through a save.
|
|
22
|
+
*
|
|
23
|
+
* @capability modal-stack serializable, observable stack of opaque modal/dialog records — push/pop/resolve blocking overlays (pause menu, confirm dialog) with optional auto-dismiss; never interprets kind or result
|
|
24
|
+
*/
|
|
25
|
+
export function createModalStack(options = {}) {
|
|
26
|
+
const now = options.now ?? Date.now;
|
|
27
|
+
const onOpen = options.onOpen;
|
|
28
|
+
const onResolve = options.onResolve;
|
|
29
|
+
const listeners = new Set();
|
|
30
|
+
let entries = [];
|
|
31
|
+
let seq = 0;
|
|
32
|
+
let last = null;
|
|
33
|
+
function notify() {
|
|
34
|
+
for (const listener of listeners)
|
|
35
|
+
listener();
|
|
36
|
+
}
|
|
37
|
+
function nextId() {
|
|
38
|
+
return `modal-${seq++}`;
|
|
39
|
+
}
|
|
40
|
+
function toRecord(input) {
|
|
41
|
+
const record = { id: input.id ?? nextId(), kind: input.kind };
|
|
42
|
+
if (input.payload !== undefined)
|
|
43
|
+
record.payload = input.payload;
|
|
44
|
+
if (input.timeoutMs !== undefined)
|
|
45
|
+
record.timeoutMs = input.timeoutMs;
|
|
46
|
+
if (input.timeoutResult !== undefined)
|
|
47
|
+
record.timeoutResult = input.timeoutResult;
|
|
48
|
+
return record;
|
|
49
|
+
}
|
|
50
|
+
/** @internal Remove the entry at `index` and produce+record its resolution. Does not notify. */
|
|
51
|
+
function resolveAt(index, result, payload, at = now()) {
|
|
52
|
+
const [entry] = entries.splice(index, 1);
|
|
53
|
+
const record = entry.record;
|
|
54
|
+
const resolution = {
|
|
55
|
+
id: record.id,
|
|
56
|
+
kind: record.kind,
|
|
57
|
+
result,
|
|
58
|
+
at,
|
|
59
|
+
};
|
|
60
|
+
const echo = payload !== undefined ? payload : record.payload;
|
|
61
|
+
if (echo !== undefined)
|
|
62
|
+
resolution.payload = echo;
|
|
63
|
+
last = resolution;
|
|
64
|
+
onResolve?.(resolution);
|
|
65
|
+
return resolution;
|
|
66
|
+
}
|
|
67
|
+
function push(input) {
|
|
68
|
+
const record = toRecord(input);
|
|
69
|
+
entries.push({ record, openedAt: now() });
|
|
70
|
+
onOpen?.(record);
|
|
71
|
+
notify();
|
|
72
|
+
return record;
|
|
73
|
+
}
|
|
74
|
+
for (const input of options.initial ?? [])
|
|
75
|
+
push(input);
|
|
76
|
+
return {
|
|
77
|
+
push,
|
|
78
|
+
pop() {
|
|
79
|
+
if (entries.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
const record = entries[entries.length - 1].record;
|
|
82
|
+
resolveAt(entries.length - 1, MODAL_CANCEL);
|
|
83
|
+
notify();
|
|
84
|
+
return record;
|
|
85
|
+
},
|
|
86
|
+
resolve(result, opts) {
|
|
87
|
+
const index = opts?.id !== undefined
|
|
88
|
+
? entries.findIndex((entry) => entry.record.id === opts.id)
|
|
89
|
+
: entries.length - 1;
|
|
90
|
+
if (index < 0)
|
|
91
|
+
return null;
|
|
92
|
+
const resolution = resolveAt(index, result, opts?.payload);
|
|
93
|
+
notify();
|
|
94
|
+
return resolution;
|
|
95
|
+
},
|
|
96
|
+
top() {
|
|
97
|
+
return entries.length === 0 ? null : entries[entries.length - 1].record;
|
|
98
|
+
},
|
|
99
|
+
isOpen() {
|
|
100
|
+
return entries.length > 0;
|
|
101
|
+
},
|
|
102
|
+
has(id) {
|
|
103
|
+
return entries.some((entry) => entry.record.id === id);
|
|
104
|
+
},
|
|
105
|
+
depth() {
|
|
106
|
+
return entries.length;
|
|
107
|
+
},
|
|
108
|
+
stack() {
|
|
109
|
+
return entries.map((entry) => entry.record);
|
|
110
|
+
},
|
|
111
|
+
lastResolution() {
|
|
112
|
+
return last;
|
|
113
|
+
},
|
|
114
|
+
clear() {
|
|
115
|
+
if (entries.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
while (entries.length > 0)
|
|
118
|
+
resolveAt(entries.length - 1, MODAL_CANCEL);
|
|
119
|
+
notify();
|
|
120
|
+
},
|
|
121
|
+
tick(nowOverride) {
|
|
122
|
+
const at = nowOverride ?? now();
|
|
123
|
+
const fired = [];
|
|
124
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
125
|
+
const entry = entries[i];
|
|
126
|
+
const timeout = entry.record.timeoutMs;
|
|
127
|
+
if (timeout !== undefined && at - entry.openedAt >= timeout) {
|
|
128
|
+
fired.push(resolveAt(i, entry.record.timeoutResult ?? MODAL_CANCEL, undefined, at));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (fired.length > 0)
|
|
132
|
+
notify();
|
|
133
|
+
return fired;
|
|
134
|
+
},
|
|
135
|
+
timeRemaining(id) {
|
|
136
|
+
const entry = id !== undefined
|
|
137
|
+
? entries.find((e) => e.record.id === id)
|
|
138
|
+
: entries[entries.length - 1];
|
|
139
|
+
if (entry === undefined || entry.record.timeoutMs === undefined)
|
|
140
|
+
return null;
|
|
141
|
+
return Math.max(0, entry.openedAt + entry.record.timeoutMs - now());
|
|
142
|
+
},
|
|
143
|
+
subscribe(listener) {
|
|
144
|
+
listeners.add(listener);
|
|
145
|
+
return () => listeners.delete(listener);
|
|
146
|
+
},
|
|
147
|
+
snapshot() {
|
|
148
|
+
return {
|
|
149
|
+
entries: entries.map((entry) => ({ record: { ...entry.record }, openedAt: entry.openedAt })),
|
|
150
|
+
seq,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
restore(snapshot) {
|
|
154
|
+
entries = snapshot.entries.map((entry) => ({ record: { ...entry.record }, openedAt: entry.openedAt }));
|
|
155
|
+
seq = snapshot.seq;
|
|
156
|
+
last = null;
|
|
157
|
+
notify();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which part of its life a banner is in: flying `"in"`, holding centered, or
|
|
3
|
+
* fading `"out"`. Progress `0..1` (see {@link ObjectiveBannerView.progress})
|
|
4
|
+
* runs within whichever phase is active — the renderer maps phase + progress to
|
|
5
|
+
* opacity/scale/translate however it likes.
|
|
6
|
+
*/
|
|
7
|
+
export type ObjectiveBannerPhase = "in" | "hold" | "out";
|
|
8
|
+
/** One announcement request handed to {@link ObjectiveBannerController.announce}. */
|
|
9
|
+
export interface ObjectiveBannerAnnouncement {
|
|
10
|
+
/** The big centered headline (e.g. `"WAVE 3"`, `"VICTORY"`). */
|
|
11
|
+
title: string;
|
|
12
|
+
/** Optional smaller line under the title (e.g. `"Defend the core"`). */
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Free-string label the game owns and styles ("wave", "victory", "defeat",
|
|
16
|
+
* "objective", …); the model never interprets it — it only carries it through
|
|
17
|
+
* to the renderer so a game can color/skin per kind. Default `"default"`.
|
|
18
|
+
*/
|
|
19
|
+
kind?: string;
|
|
20
|
+
/** Fly-in duration in ms. Default `350`. */
|
|
21
|
+
inMs?: number;
|
|
22
|
+
/** Centered hold duration in ms. Default `2000`. */
|
|
23
|
+
holdMs?: number;
|
|
24
|
+
/** Fade-out duration in ms. Default `500`. */
|
|
25
|
+
outMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The banner to draw right now: its content, the game-owned `kind`, the current
|
|
29
|
+
* {@link ObjectiveBannerPhase}, and `progress` `0..1` through that phase. This
|
|
30
|
+
* object is pooled and overwritten on the next {@link ObjectiveBannerController.current}
|
|
31
|
+
* call — read it, don't retain it.
|
|
32
|
+
*/
|
|
33
|
+
export interface ObjectiveBannerView {
|
|
34
|
+
/** Stable id assigned at announce time. */
|
|
35
|
+
id: number;
|
|
36
|
+
/** The headline. */
|
|
37
|
+
title: string;
|
|
38
|
+
/** The optional subtitle, or `undefined`. */
|
|
39
|
+
subtitle: string | undefined;
|
|
40
|
+
/** The game-owned free-string label. */
|
|
41
|
+
kind: string;
|
|
42
|
+
/** Which phase the banner is in. */
|
|
43
|
+
phase: ObjectiveBannerPhase;
|
|
44
|
+
/** Progress `0..1` within the current phase. */
|
|
45
|
+
progress: number;
|
|
46
|
+
}
|
|
47
|
+
/** A persisted banner record — all announcement fields resolved, plus id and schedule. */
|
|
48
|
+
export interface StoredObjectiveBanner {
|
|
49
|
+
id: number;
|
|
50
|
+
title: string;
|
|
51
|
+
subtitle: string | undefined;
|
|
52
|
+
kind: string;
|
|
53
|
+
inMs: number;
|
|
54
|
+
holdMs: number;
|
|
55
|
+
outMs: number;
|
|
56
|
+
/** Clock time (ms) this banner started its fly-in, or `null` while still queued. */
|
|
57
|
+
startAt: number | null;
|
|
58
|
+
}
|
|
59
|
+
/** Serializable state of the queue and the active banner, for save/restore. */
|
|
60
|
+
export interface ObjectiveBannerSnapshot {
|
|
61
|
+
/** Wall-clock (ms) the snapshot was taken, so restore re-anchors elapsed time. */
|
|
62
|
+
now: number;
|
|
63
|
+
/** Next id to hand out. */
|
|
64
|
+
nextId: number;
|
|
65
|
+
/** The active banner (head) followed by everything still queued, in order. */
|
|
66
|
+
queue: readonly StoredObjectiveBanner[];
|
|
67
|
+
}
|
|
68
|
+
/** A live, clock-driven objective/stage banner controller. */
|
|
69
|
+
export interface ObjectiveBannerController {
|
|
70
|
+
/**
|
|
71
|
+
* Enqueue a banner. If nothing is showing it starts flying in immediately;
|
|
72
|
+
* otherwise it waits its turn (one banner shows at a time). Returns the new id.
|
|
73
|
+
*/
|
|
74
|
+
announce(announcement: ObjectiveBannerAnnouncement): number;
|
|
75
|
+
/**
|
|
76
|
+
* Advance the clock: transition the active banner through in → hold → out,
|
|
77
|
+
* reap it when its life ends, and start the next queued banner. Pass an explicit
|
|
78
|
+
* `nowMs` to drive deterministically, else the injected clock is read. Notifies
|
|
79
|
+
* subscribers. Call it each frame from the renderer.
|
|
80
|
+
*/
|
|
81
|
+
advance(nowMs?: number): void;
|
|
82
|
+
/** The banner to draw right now (pooled — do not retain), or `null` when idle. */
|
|
83
|
+
current(): ObjectiveBannerView | null;
|
|
84
|
+
/** Dismiss the active banner immediately and start the next queued one, if any. */
|
|
85
|
+
skip(): void;
|
|
86
|
+
/** Drop the active banner and everything queued. */
|
|
87
|
+
clear(): void;
|
|
88
|
+
/** How many banners are waiting behind the active one. */
|
|
89
|
+
pending(): number;
|
|
90
|
+
/** Whether a banner is currently active. */
|
|
91
|
+
isActive(): boolean;
|
|
92
|
+
/** Observe changes (announce, advance, skip, clear, restore). Returns an unsubscribe fn. */
|
|
93
|
+
subscribe(listener: () => void): () => void;
|
|
94
|
+
/** Serializable state for a save. */
|
|
95
|
+
snapshot(): ObjectiveBannerSnapshot;
|
|
96
|
+
/** Restore from an {@link ObjectiveBannerSnapshot}. */
|
|
97
|
+
restore(snapshot: ObjectiveBannerSnapshot): void;
|
|
98
|
+
}
|
|
99
|
+
/** Options for {@link createObjectiveBanner}. */
|
|
100
|
+
export interface ObjectiveBannerOptions {
|
|
101
|
+
/** Injected clock (ms). Default `Date.now`. */
|
|
102
|
+
now?: () => number;
|
|
103
|
+
/** Default fly-in ms for announcements that omit `inMs`. Default `350`. */
|
|
104
|
+
inMs?: number;
|
|
105
|
+
/** Default hold ms for announcements that omit `holdMs`. Default `2000`. */
|
|
106
|
+
holdMs?: number;
|
|
107
|
+
/** Default fade-out ms for announcements that omit `outMs`. Default `500`. */
|
|
108
|
+
outMs?: number;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A serializable, clock-driven stage/objective banner controller: the classic
|
|
112
|
+
* transient centered title stamp ("WAVE 3", "VICTORY", "OBJECTIVE COMPLETE"). A
|
|
113
|
+
* game calls `announce({ title, subtitle?, kind? })` and the controller queues it,
|
|
114
|
+
* flies it in, holds it, and fades it out on an injected clock — one banner at a
|
|
115
|
+
* time — exposing the current banner plus its phase (in/hold/out) and `progress`
|
|
116
|
+
* `0..1` for a renderer to animate opacity/scale/translate. Nothing here is
|
|
117
|
+
* genre-specific: `kind` is a free label the game owns and styles, and all timings
|
|
118
|
+
* are parameters. Call `advance()` each frame; `current()` returns the pooled view
|
|
119
|
+
* to draw; `snapshot`/`restore` round-trips the queue through a save.
|
|
120
|
+
*
|
|
121
|
+
* @capability objective-banner serializable, clock-driven stage/objective banner queue — flies a big transient centered title + optional subtitle in, holds, and fades out one at a time, with phase + progress for animation, free-string kinds the game styles, and snapshot/restore
|
|
122
|
+
*/
|
|
123
|
+
export declare function createObjectiveBanner(options?: ObjectiveBannerOptions): ObjectiveBannerController;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
function clamp01(v) {
|
|
2
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* A serializable, clock-driven stage/objective banner controller: the classic
|
|
6
|
+
* transient centered title stamp ("WAVE 3", "VICTORY", "OBJECTIVE COMPLETE"). A
|
|
7
|
+
* game calls `announce({ title, subtitle?, kind? })` and the controller queues it,
|
|
8
|
+
* flies it in, holds it, and fades it out on an injected clock — one banner at a
|
|
9
|
+
* time — exposing the current banner plus its phase (in/hold/out) and `progress`
|
|
10
|
+
* `0..1` for a renderer to animate opacity/scale/translate. Nothing here is
|
|
11
|
+
* genre-specific: `kind` is a free label the game owns and styles, and all timings
|
|
12
|
+
* are parameters. Call `advance()` each frame; `current()` returns the pooled view
|
|
13
|
+
* to draw; `snapshot`/`restore` round-trips the queue through a save.
|
|
14
|
+
*
|
|
15
|
+
* @capability objective-banner serializable, clock-driven stage/objective banner queue — flies a big transient centered title + optional subtitle in, holds, and fades out one at a time, with phase + progress for animation, free-string kinds the game styles, and snapshot/restore
|
|
16
|
+
*/
|
|
17
|
+
export function createObjectiveBanner(options = {}) {
|
|
18
|
+
const now = options.now ?? Date.now;
|
|
19
|
+
const defaultIn = Math.max(0, options.inMs ?? 350);
|
|
20
|
+
const defaultHold = Math.max(0, options.holdMs ?? 2000);
|
|
21
|
+
const defaultOut = Math.max(0, options.outMs ?? 500);
|
|
22
|
+
// queue[0] is the active banner; the rest wait their turn.
|
|
23
|
+
const queue = [];
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
// Pooled view — reused across current() calls, never per-frame allocated.
|
|
26
|
+
const view = { id: 0, title: "", subtitle: undefined, kind: "", phase: "in", progress: 0 };
|
|
27
|
+
let nextId = 1;
|
|
28
|
+
function notify() {
|
|
29
|
+
for (const listener of listeners)
|
|
30
|
+
listener();
|
|
31
|
+
}
|
|
32
|
+
/** Total lifetime of a banner in ms. */
|
|
33
|
+
function totalMs(banner) {
|
|
34
|
+
return banner.inMs + banner.holdMs + banner.outMs;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Activate/chain the head, reap any that have fully expired at `t`, and cache
|
|
38
|
+
* the active banner's phase + progress. Deterministic regardless of how often
|
|
39
|
+
* it is called: a finished banner hands its scheduled end time to the next.
|
|
40
|
+
*/
|
|
41
|
+
function recompute(t) {
|
|
42
|
+
while (queue.length > 0) {
|
|
43
|
+
const head = queue[0];
|
|
44
|
+
if (head.startAt === null)
|
|
45
|
+
head.startAt = t;
|
|
46
|
+
const total = totalMs(head);
|
|
47
|
+
const elapsed = t - head.startAt;
|
|
48
|
+
if (total <= 0 || elapsed >= total) {
|
|
49
|
+
queue.shift();
|
|
50
|
+
const next = queue[0];
|
|
51
|
+
if (next !== undefined && next.startAt === null)
|
|
52
|
+
next.startAt = head.startAt + total;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (elapsed < head.inMs) {
|
|
56
|
+
head.phase = "in";
|
|
57
|
+
head.progress = head.inMs > 0 ? clamp01(elapsed / head.inMs) : 1;
|
|
58
|
+
}
|
|
59
|
+
else if (elapsed < head.inMs + head.holdMs) {
|
|
60
|
+
head.phase = "hold";
|
|
61
|
+
head.progress = head.holdMs > 0 ? clamp01((elapsed - head.inMs) / head.holdMs) : 1;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
head.phase = "out";
|
|
65
|
+
head.progress = head.outMs > 0 ? clamp01((elapsed - head.inMs - head.holdMs) / head.outMs) : 1;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
announce(announcement) {
|
|
72
|
+
const id = nextId++;
|
|
73
|
+
queue.push({
|
|
74
|
+
id,
|
|
75
|
+
title: announcement.title,
|
|
76
|
+
subtitle: announcement.subtitle,
|
|
77
|
+
kind: announcement.kind ?? "default",
|
|
78
|
+
inMs: Math.max(0, announcement.inMs ?? defaultIn),
|
|
79
|
+
holdMs: Math.max(0, announcement.holdMs ?? defaultHold),
|
|
80
|
+
outMs: Math.max(0, announcement.outMs ?? defaultOut),
|
|
81
|
+
startAt: null,
|
|
82
|
+
phase: "in",
|
|
83
|
+
progress: 0,
|
|
84
|
+
});
|
|
85
|
+
recompute(now());
|
|
86
|
+
notify();
|
|
87
|
+
return id;
|
|
88
|
+
},
|
|
89
|
+
advance(nowMs) {
|
|
90
|
+
recompute(nowMs ?? now());
|
|
91
|
+
notify();
|
|
92
|
+
},
|
|
93
|
+
current() {
|
|
94
|
+
const head = queue[0];
|
|
95
|
+
if (head === undefined || head.startAt === null)
|
|
96
|
+
return null;
|
|
97
|
+
view.id = head.id;
|
|
98
|
+
view.title = head.title;
|
|
99
|
+
view.subtitle = head.subtitle;
|
|
100
|
+
view.kind = head.kind;
|
|
101
|
+
view.phase = head.phase;
|
|
102
|
+
view.progress = head.progress;
|
|
103
|
+
return view;
|
|
104
|
+
},
|
|
105
|
+
skip() {
|
|
106
|
+
if (queue.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
queue.shift();
|
|
109
|
+
recompute(now());
|
|
110
|
+
notify();
|
|
111
|
+
},
|
|
112
|
+
clear() {
|
|
113
|
+
if (queue.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
queue.length = 0;
|
|
116
|
+
notify();
|
|
117
|
+
},
|
|
118
|
+
pending() {
|
|
119
|
+
return queue.length === 0 ? 0 : queue.length - 1;
|
|
120
|
+
},
|
|
121
|
+
isActive() {
|
|
122
|
+
const head = queue[0];
|
|
123
|
+
return head !== undefined && head.startAt !== null;
|
|
124
|
+
},
|
|
125
|
+
subscribe(listener) {
|
|
126
|
+
listeners.add(listener);
|
|
127
|
+
return () => listeners.delete(listener);
|
|
128
|
+
},
|
|
129
|
+
snapshot() {
|
|
130
|
+
return {
|
|
131
|
+
now: now(),
|
|
132
|
+
nextId,
|
|
133
|
+
queue: queue.map((banner) => ({
|
|
134
|
+
id: banner.id,
|
|
135
|
+
title: banner.title,
|
|
136
|
+
subtitle: banner.subtitle,
|
|
137
|
+
kind: banner.kind,
|
|
138
|
+
inMs: banner.inMs,
|
|
139
|
+
holdMs: banner.holdMs,
|
|
140
|
+
outMs: banner.outMs,
|
|
141
|
+
startAt: banner.startAt,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
restore(snapshot) {
|
|
146
|
+
queue.length = 0;
|
|
147
|
+
nextId = snapshot.nextId;
|
|
148
|
+
const t = now();
|
|
149
|
+
const drift = t - snapshot.now;
|
|
150
|
+
for (const stored of snapshot.queue) {
|
|
151
|
+
queue.push({
|
|
152
|
+
...stored,
|
|
153
|
+
startAt: stored.startAt === null ? null : stored.startAt + drift,
|
|
154
|
+
phase: "in",
|
|
155
|
+
progress: 0,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
recompute(t);
|
|
159
|
+
notify();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Serializable photo-mode preferences a game reads to compose its capture UI. */
|
|
2
|
+
export interface PhotoModeState {
|
|
3
|
+
/** Photo mode is engaged (game frees the camera / pauses as it sees fit). */
|
|
4
|
+
active: boolean;
|
|
5
|
+
/** Hide the gameplay HUD for a clean shot. */
|
|
6
|
+
hideHud: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** The out-of-the-box photo-mode state: not active, HUD hidden when it engages. */
|
|
9
|
+
export declare const DEFAULT_PHOTO_MODE: PhotoModeState;
|
|
10
|
+
/** Observable photo-mode store — genre-agnostic state a game binds its capture flow to. */
|
|
11
|
+
export interface PhotoModeStore {
|
|
12
|
+
get(): PhotoModeState;
|
|
13
|
+
/** Engage photo mode. */
|
|
14
|
+
enter(): void;
|
|
15
|
+
/** Leave photo mode. */
|
|
16
|
+
exit(): void;
|
|
17
|
+
toggle(): void;
|
|
18
|
+
setHideHud(hide: boolean): void;
|
|
19
|
+
set(patch: Partial<PhotoModeState>): void;
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
snapshot(): PhotoModeState;
|
|
22
|
+
restore(snapshot: Partial<PhotoModeState>): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Serializable, observable photo-mode state (active + hide-HUD). Genre-agnostic
|
|
26
|
+
* plumbing — a game engages it to free its camera and hide gameplay chrome for a
|
|
27
|
+
* clean capture, and reads `hideHud` to conditionally render its HUD. Pair with
|
|
28
|
+
* React `PhotoModeControls` and shell `captureCanvas`.
|
|
29
|
+
*
|
|
30
|
+
* @capability photo-mode observable photo-mode state (active + hide-HUD) a game binds its screenshot/camera flow to
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPhotoModeStore(initial?: Partial<PhotoModeState>): PhotoModeStore;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The out-of-the-box photo-mode state: not active, HUD hidden when it engages. */
|
|
2
|
+
export const DEFAULT_PHOTO_MODE = { active: false, hideHud: true };
|
|
3
|
+
/**
|
|
4
|
+
* Serializable, observable photo-mode state (active + hide-HUD). Genre-agnostic
|
|
5
|
+
* plumbing — a game engages it to free its camera and hide gameplay chrome for a
|
|
6
|
+
* clean capture, and reads `hideHud` to conditionally render its HUD. Pair with
|
|
7
|
+
* React `PhotoModeControls` and shell `captureCanvas`.
|
|
8
|
+
*
|
|
9
|
+
* @capability photo-mode observable photo-mode state (active + hide-HUD) a game binds its screenshot/camera flow to
|
|
10
|
+
*/
|
|
11
|
+
export function createPhotoModeStore(initial = {}) {
|
|
12
|
+
let state = { ...DEFAULT_PHOTO_MODE, ...initial };
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
function commit(next) {
|
|
15
|
+
if (next.active === state.active && next.hideHud === state.hideHud)
|
|
16
|
+
return;
|
|
17
|
+
state = next;
|
|
18
|
+
for (const listener of listeners)
|
|
19
|
+
listener();
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
get() {
|
|
23
|
+
return state;
|
|
24
|
+
},
|
|
25
|
+
enter() {
|
|
26
|
+
commit({ ...state, active: true });
|
|
27
|
+
},
|
|
28
|
+
exit() {
|
|
29
|
+
commit({ ...state, active: false });
|
|
30
|
+
},
|
|
31
|
+
toggle() {
|
|
32
|
+
commit({ ...state, active: !state.active });
|
|
33
|
+
},
|
|
34
|
+
setHideHud(hide) {
|
|
35
|
+
commit({ ...state, hideHud: hide });
|
|
36
|
+
},
|
|
37
|
+
set(patch) {
|
|
38
|
+
commit({ ...state, ...patch });
|
|
39
|
+
},
|
|
40
|
+
subscribe(listener) {
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
return () => listeners.delete(listener);
|
|
43
|
+
},
|
|
44
|
+
snapshot() {
|
|
45
|
+
return state;
|
|
46
|
+
},
|
|
47
|
+
restore(snapshot) {
|
|
48
|
+
commit({ ...DEFAULT_PHOTO_MODE, ...snapshot });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arc the slices span. Omit for a full wheel (index 0 at the top). A partial
|
|
3
|
+
* `sweep` (e.g. `Math.PI` for a bottom half, `Math.PI / 2` for a quarter) packs
|
|
4
|
+
* the slices evenly inside `[startAngle, startAngle + sweep]` — the arc/quick-bar
|
|
5
|
+
* forms. Angles are radians from "up" (−Y), clockwise.
|
|
6
|
+
*/
|
|
7
|
+
export interface RadialArc {
|
|
8
|
+
startAngle?: number;
|
|
9
|
+
sweep?: number;
|
|
10
|
+
}
|
|
11
|
+
/** One wedge of a radial menu — geometry for rendering a slice. Angles are radians from "up" (−Y), clockwise. */
|
|
12
|
+
export interface RadialSlice {
|
|
13
|
+
index: number;
|
|
14
|
+
/** Angle at the slice's center. */
|
|
15
|
+
centerAngle: number;
|
|
16
|
+
/** Leading edge angle. */
|
|
17
|
+
startAngle: number;
|
|
18
|
+
/** Trailing edge angle. */
|
|
19
|
+
endAngle: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Divide a ring (or `arc`) into `count` wedges — the geometry a radial/quick
|
|
23
|
+
* menu (weapon wheel, emote arc, ping bar) renders. A full wheel centers index 0
|
|
24
|
+
* at the top; a partial `arc` packs the slices inside its sweep.
|
|
25
|
+
*/
|
|
26
|
+
export declare function radialSlices(count: number, arc?: RadialArc): RadialSlice[];
|
|
27
|
+
/** Map an angle to a slice index; returns -1 when the angle falls outside a partial `arc`. */
|
|
28
|
+
export declare function radialIndexFromAngle(angle: number, count: number, arc?: RadialArc): number;
|
|
29
|
+
/** Options for {@link radialIndexFromVector}. */
|
|
30
|
+
export interface RadialVectorOptions extends RadialArc {
|
|
31
|
+
/** Minimum vector magnitude to register a selection; shorter vectors return null (the neutral center). Default 0. */
|
|
32
|
+
deadZone?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pick the slice a pointer/stick vector points at. `dx`/`dy` are screen-space
|
|
36
|
+
* (y down): up is `(0, -1)`. Returns null inside `deadZone` or (for a partial
|
|
37
|
+
* `arc`) outside the arc, so a centered stick or a click off the arc selects
|
|
38
|
+
* nothing.
|
|
39
|
+
*
|
|
40
|
+
* @capability radial-select map a pointer/stick vector to a radial-menu slice index, with a neutral dead zone and optional arc bounds
|
|
41
|
+
*/
|
|
42
|
+
export declare function radialIndexFromVector(dx: number, dy: number, count: number, options?: RadialVectorOptions): number | null;
|
|
43
|
+
/** Unit-circle position of a slice center (up = `(0, -1)`), scaled by `radius` — where to place a wedge's icon/label. */
|
|
44
|
+
export declare function radialSlicePosition(index: number, count: number, radius: number, arc?: RadialArc): {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const TWO_PI = Math.PI * 2;
|
|
2
|
+
function normalize(angle) {
|
|
3
|
+
let value = angle % TWO_PI;
|
|
4
|
+
if (value < 0)
|
|
5
|
+
value += TWO_PI;
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function arcConfig(count, arc) {
|
|
9
|
+
const startAngle = arc?.startAngle ?? 0;
|
|
10
|
+
const sweep = arc?.sweep ?? TWO_PI;
|
|
11
|
+
const full = sweep >= TWO_PI - 1e-6;
|
|
12
|
+
const size = full ? (count > 0 ? TWO_PI / count : 0) : (count > 0 ? sweep / count : 0);
|
|
13
|
+
// Full wheels wrap, so a slice sits exactly on the start angle (index 0 = top).
|
|
14
|
+
// Partial arcs offset by half a slice so wedges fit inside the arc.
|
|
15
|
+
const centerOf = (index) => startAngle + (full ? index : index + 0.5) * size;
|
|
16
|
+
return { startAngle, sweep, full, size, centerOf };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Divide a ring (or `arc`) into `count` wedges — the geometry a radial/quick
|
|
20
|
+
* menu (weapon wheel, emote arc, ping bar) renders. A full wheel centers index 0
|
|
21
|
+
* at the top; a partial `arc` packs the slices inside its sweep.
|
|
22
|
+
*/
|
|
23
|
+
export function radialSlices(count, arc) {
|
|
24
|
+
if (count <= 0)
|
|
25
|
+
return [];
|
|
26
|
+
const { size, centerOf } = arcConfig(count, arc);
|
|
27
|
+
const slices = [];
|
|
28
|
+
for (let index = 0; index < count; index += 1) {
|
|
29
|
+
const centerAngle = centerOf(index);
|
|
30
|
+
slices.push({
|
|
31
|
+
index,
|
|
32
|
+
centerAngle: normalize(centerAngle),
|
|
33
|
+
startAngle: normalize(centerAngle - size / 2),
|
|
34
|
+
endAngle: normalize(centerAngle + size / 2),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return slices;
|
|
38
|
+
}
|
|
39
|
+
/** Map an angle to a slice index; returns -1 when the angle falls outside a partial `arc`. */
|
|
40
|
+
export function radialIndexFromAngle(angle, count, arc) {
|
|
41
|
+
if (count <= 0)
|
|
42
|
+
return -1;
|
|
43
|
+
const { full, size, startAngle, sweep } = arcConfig(count, arc);
|
|
44
|
+
if (full)
|
|
45
|
+
return Math.round(normalize(angle - startAngle) / size) % count;
|
|
46
|
+
const rel = normalize(angle - startAngle);
|
|
47
|
+
if (rel > sweep)
|
|
48
|
+
return -1;
|
|
49
|
+
return Math.max(0, Math.min(count - 1, Math.floor(rel / size)));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pick the slice a pointer/stick vector points at. `dx`/`dy` are screen-space
|
|
53
|
+
* (y down): up is `(0, -1)`. Returns null inside `deadZone` or (for a partial
|
|
54
|
+
* `arc`) outside the arc, so a centered stick or a click off the arc selects
|
|
55
|
+
* nothing.
|
|
56
|
+
*
|
|
57
|
+
* @capability radial-select map a pointer/stick vector to a radial-menu slice index, with a neutral dead zone and optional arc bounds
|
|
58
|
+
*/
|
|
59
|
+
export function radialIndexFromVector(dx, dy, count, options = {}) {
|
|
60
|
+
if (count <= 0)
|
|
61
|
+
return null;
|
|
62
|
+
const magnitude = Math.hypot(dx, dy);
|
|
63
|
+
if (magnitude < (options.deadZone ?? 0))
|
|
64
|
+
return null;
|
|
65
|
+
// atan2(dx, -dy): 0 = up, increasing clockwise toward +x (right).
|
|
66
|
+
const index = radialIndexFromAngle(Math.atan2(dx, -dy), count, options);
|
|
67
|
+
return index < 0 ? null : index;
|
|
68
|
+
}
|
|
69
|
+
/** Unit-circle position of a slice center (up = `(0, -1)`), scaled by `radius` — where to place a wedge's icon/label. */
|
|
70
|
+
export function radialSlicePosition(index, count, radius, arc) {
|
|
71
|
+
const { centerOf } = arcConfig(count, arc);
|
|
72
|
+
const angle = count <= 0 ? 0 : centerOf(index);
|
|
73
|
+
return { x: Math.sin(angle) * radius, y: -Math.cos(angle) * radius };
|
|
74
|
+
}
|