@zakkster/lite-camera-pro 1.0.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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Parallax Layer Manager
3
+ *
4
+ * Manages multiple scroll layers at different speeds.
5
+ * Each layer has a scroll multiplier relative to the camera.
6
+ *
7
+ * speed 1.0 = scrolls with camera (normal game layer)
8
+ * speed 0.5 = scrolls at half speed (distant background)
9
+ * speed 1.5 = scrolls faster (foreground)
10
+ * speed 0.0 = fixed (UI, sky)
11
+ *
12
+ * Zero allocations per frame. All layer state is pre-allocated.
13
+ *
14
+ * Depends on: nothing (pure math)
15
+ */
16
+
17
+ // ── Maximum layers ──
18
+ const MAX_LAYERS = 16;
19
+
20
+ /** Wrap modes for layer tiling */
21
+ export const WrapMode = {
22
+ NONE: 0, // no wrapping
23
+ REPEAT_X: 1, // tile horizontally
24
+ REPEAT_Y: 2, // tile vertically
25
+ REPEAT_BOTH: 3, // tile both axes
26
+ };
27
+
28
+ /**
29
+ * Create a single parallax layer (pre-allocated).
30
+ * @returns {Object}
31
+ */
32
+ function createLayer() {
33
+ return {
34
+ active: false,
35
+ id: '',
36
+
37
+ // Scroll speed multipliers (1.0 = normal camera speed)
38
+ speedX: 1.0,
39
+ speedY: 1.0,
40
+
41
+ // Manual offset (for fine-tuning layer position)
42
+ offsetX: 0,
43
+ offsetY: 0,
44
+
45
+ // Wrap mode
46
+ wrap: WrapMode.NONE,
47
+
48
+ // Computed scroll position (updated each frame)
49
+ scrollX: 0,
50
+ scrollY: 0,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Create the parallax manager state. Allocated once per camera.
56
+ *
57
+ * @returns {Object} ParallaxState
58
+ */
59
+ export function createParallaxState() {
60
+ const layers = new Array(MAX_LAYERS);
61
+ for (let i = 0; i < MAX_LAYERS; i++) {
62
+ layers[i] = createLayer();
63
+ }
64
+
65
+ return {
66
+ layers,
67
+ layerCount: MAX_LAYERS,
68
+ activeCount: 0,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Add or update a parallax layer.
74
+ *
75
+ * @param {Object} state ParallaxState (cam._parallax)
76
+ * @param {string} id Unique layer identifier
77
+ * @param {number} speedX Horizontal scroll multiplier
78
+ * @param {number} [speedY] Vertical scroll multiplier (defaults to speedX)
79
+ * @param {Object} [opts]
80
+ * @param {number} [opts.offsetX=0] Manual X offset
81
+ * @param {number} [opts.offsetY=0] Manual Y offset
82
+ * @param {number} [opts.wrap=0] WrapMode enum
83
+ * @returns {Object} The layer object (for direct mutation if needed)
84
+ */
85
+ export function addParallaxLayer(state, id, speedX, speedY, opts) {
86
+ if (speedY === undefined) speedY = speedX;
87
+
88
+ // Check if layer already exists
89
+ for (let i = 0; i < state.layerCount; i++) {
90
+ if (state.layers[i].active && state.layers[i].id === id) {
91
+ const layer = state.layers[i];
92
+ layer.speedX = speedX;
93
+ layer.speedY = speedY;
94
+ if (opts) {
95
+ if (opts.offsetX !== undefined) layer.offsetX = opts.offsetX;
96
+ if (opts.offsetY !== undefined) layer.offsetY = opts.offsetY;
97
+ if (opts.wrap !== undefined) layer.wrap = opts.wrap;
98
+ }
99
+ return layer;
100
+ }
101
+ }
102
+
103
+ // Find first inactive slot
104
+ for (let i = 0; i < state.layerCount; i++) {
105
+ if (!state.layers[i].active) {
106
+ const layer = state.layers[i];
107
+ layer.active = true;
108
+ layer.id = id;
109
+ layer.speedX = speedX;
110
+ layer.speedY = speedY;
111
+ layer.offsetX = (opts && opts.offsetX) || 0;
112
+ layer.offsetY = (opts && opts.offsetY) || 0;
113
+ layer.wrap = (opts && opts.wrap) || WrapMode.NONE;
114
+ layer.scrollX = 0;
115
+ layer.scrollY = 0;
116
+ state.activeCount++;
117
+ return layer;
118
+ }
119
+ }
120
+
121
+ return null; // all slots full
122
+ }
123
+
124
+ /**
125
+ * Remove a parallax layer by id.
126
+ *
127
+ * @param {Object} state ParallaxState
128
+ * @param {string} id Layer id
129
+ */
130
+ export function removeParallaxLayer(state, id) {
131
+ for (let i = 0; i < state.layerCount; i++) {
132
+ if (state.layers[i].active && state.layers[i].id === id) {
133
+ state.layers[i].active = false;
134
+ state.layers[i].id = '';
135
+ state.activeCount--;
136
+ return;
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Update all layer scroll positions based on camera position and zoom.
143
+ * Called once per frame from camera.update().
144
+ *
145
+ * @param {Object} state ParallaxState
146
+ * @param {number} camX Camera top-left X (cam.pos[0])
147
+ * @param {number} camY Camera top-left Y (cam.pos[1])
148
+ * @param {number} zoom Camera zoom level
149
+ */
150
+ export function updateParallax(state, camX, camY, zoom) {
151
+ for (let i = 0; i < state.layerCount; i++) {
152
+ const layer = state.layers[i];
153
+ if (!layer.active) continue;
154
+
155
+ // Parallax scroll = camera position × speed multiplier
156
+ // Zoom scaling: faster layers should scale more with zoom
157
+ layer.scrollX = camX * layer.speedX * zoom + layer.offsetX;
158
+ layer.scrollY = camY * layer.speedY * zoom + layer.offsetY;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get a layer's scroll offset by id. Zero-alloc via out parameter.
164
+ *
165
+ * @param {Object} state ParallaxState
166
+ * @param {string} id Layer id
167
+ * @param {{x:number,y:number}} out Pre-allocated output
168
+ * @returns {{x:number,y:number}|null} out or null if not found
169
+ */
170
+ export function getLayerScroll(state, id, out) {
171
+ for (let i = 0; i < state.layerCount; i++) {
172
+ if (state.layers[i].active && state.layers[i].id === id) {
173
+ out.x = state.layers[i].scrollX;
174
+ out.y = state.layers[i].scrollY;
175
+ return out;
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Apply a parallax layer's transform to a canvas context.
183
+ * Call between ctx.save() and ctx.restore() per layer.
184
+ *
185
+ * @param {Object} state ParallaxState
186
+ * @param {string} id Layer id
187
+ * @param {CanvasRenderingContext2D} ctx
188
+ * @returns {boolean} true if layer was found and applied
189
+ */
190
+ export function applyParallaxLayer(state, id, ctx) {
191
+ for (let i = 0; i < state.layerCount; i++) {
192
+ const layer = state.layers[i];
193
+ if (layer.active && layer.id === id) {
194
+ ctx.translate(-(layer.scrollX | 0), -(layer.scrollY | 0));
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Noise-Based Shake Engine
3
+ *
4
+ * Replaces RNG trauma shake with simplex noise for smooth, organic screen shake.
5
+ * Supports layered shakes: multiple simultaneous shake sources sum together.
6
+ *
7
+ * Zero-GC: Pre-allocated shake slot pool. No allocations in update/compute.
8
+ *
9
+ * Depends on: @zakkster/lite-noise (simplex2)
10
+ */
11
+
12
+ import {simplex2} from '@zakkster/lite-noise';
13
+
14
+ // ── Maximum simultaneous shake layers ──
15
+ const MAX_SHAKE_SLOTS = 8;
16
+
17
+ // ── Unique noise offsets so each slot/axis samples different noise ──
18
+ // Slot i, axis j → noise offset = NOISE_SEED_OFFSET * (i * 3 + j)
19
+ const NOISE_SEED_OFFSET = 1000;
20
+
21
+ /**
22
+ * A single shake slot. Pre-allocated, reused via pool.
23
+ * All fields are primitives — zero GC.
24
+ */
25
+ function createShakeSlot() {
26
+ return {
27
+ active: false,
28
+
29
+ // True when slot was created by addTraumaSimple (generic omni shake).
30
+ // addTrauma only stacks onto isDefault slots — preset/profile slots
31
+ // have their own freq/decay/maxOffset and shouldn't be polluted with
32
+ // generic trauma added on top.
33
+ isDefault: false,
34
+
35
+ // ── Trauma model ──
36
+ trauma: 0, // Current trauma [0, 1] — decays over time
37
+ decay: 1.0, // Trauma units lost per second
38
+
39
+ // ── Noise parameters ──
40
+ freq: 15, // Noise sample frequency (higher = more jittery)
41
+ time: 0, // Accumulated time for noise sampling
42
+
43
+ // ── Output amplitude ──
44
+ maxOffset: 15, // Maximum pixel offset at trauma=1
45
+ maxAngle: 0.05, // Maximum rotation (radians) at trauma=1
46
+
47
+ // ── Direction constraint ──
48
+ // If dirX/dirY are non-zero, shake is constrained to that axis.
49
+ // (0,0) = omnidirectional, (1,0) = horizontal only, (0,1) = vertical only
50
+ dirX: 0,
51
+ dirY: 0,
52
+ isDirectional: false,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Shake engine state. Allocated once per camera.
58
+ * Contains a pool of shake slots and the computed output.
59
+ *
60
+ * @param {number} [seedOffset=0] Per-camera offset added to every noise
61
+ * sample base. Lets two cameras with different
62
+ * seeds produce distinct, deterministic shake
63
+ * patterns without touching the global perm table.
64
+ */
65
+ export function createShakeState(seedOffset = 0) {
66
+ const slots = new Array(MAX_SHAKE_SLOTS);
67
+ for (let i = 0; i < MAX_SHAKE_SLOTS; i++) {
68
+ slots[i] = createShakeSlot();
69
+ }
70
+
71
+ return {
72
+ slots,
73
+ slotCount: MAX_SHAKE_SLOTS,
74
+
75
+ // Multiply by a prime so adjacent seeds land far apart in noise space.
76
+ seedOffset: (seedOffset | 0) * 7919,
77
+
78
+ // ── Computed output (read by apply()) ──
79
+ offsetX: 0,
80
+ offsetY: 0,
81
+ angle: 0,
82
+
83
+ // ── Global shake scale (0 = no shake, 1 = normal) ──
84
+ globalScale: 1.0,
85
+
86
+ // ── Whether any slot is active (quick check in apply) ──
87
+ active: false,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Find the first inactive slot, or the slot with lowest trauma to steal.
93
+ *
94
+ * @param {Object} state ShakeState
95
+ * @returns {Object} A shake slot
96
+ */
97
+ function acquireSlot(state) {
98
+ let minTrauma = Infinity;
99
+ let minIdx = 0;
100
+
101
+ for (let i = 0; i < state.slotCount; i++) {
102
+ if (!state.slots[i].active) return state.slots[i];
103
+ if (state.slots[i].trauma < minTrauma) {
104
+ minTrauma = state.slots[i].trauma;
105
+ minIdx = i;
106
+ }
107
+ }
108
+
109
+ // All slots full — steal the weakest
110
+ return state.slots[minIdx];
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────
114
+ // PUBLIC API
115
+ // ─────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Add a shake impulse. Acquires a slot from the pool and configures it.
119
+ *
120
+ * @param {Object} state ShakeState (cam._shake)
121
+ * @param {Object} profile Shake profile (from presets or custom)
122
+ * @param {number} profile.trauma Initial trauma [0, 1]
123
+ * @param {number} [profile.freq=15] Noise frequency
124
+ * @param {number} [profile.decay=1] Trauma decay per second
125
+ * @param {number} [profile.maxOffset=15] Max pixel offset
126
+ * @param {number} [profile.maxAngle=0.05] Max rotation (radians)
127
+ * @param {number} [profile.dirX=0] Directional X component
128
+ * @param {number} [profile.dirY=0] Directional Y component
129
+ * @param {number} [profile.intensity=1] Scale multiplier for the profile
130
+ */
131
+ export function addShake(state, profile, intensity = 1) {
132
+ const slot = acquireSlot(state);
133
+
134
+ slot.active = true;
135
+ slot.isDefault = false;
136
+ slot.trauma = Math.min(1, (profile.trauma || 0.5) * intensity);
137
+ slot.decay = profile.decay !== undefined ? profile.decay : 1.0;
138
+ slot.freq = profile.freq !== undefined ? profile.freq : 15;
139
+ slot.maxOffset = profile.maxOffset !== undefined ? profile.maxOffset : 15;
140
+ slot.maxAngle = profile.maxAngle !== undefined ? profile.maxAngle : 0.05;
141
+ slot.time = 0; // reset time for fresh noise sampling
142
+
143
+ // Directional
144
+ const dx = profile.dirX || 0;
145
+ const dy = profile.dirY || 0;
146
+ slot.isDirectional = (dx !== 0 || dy !== 0);
147
+
148
+ if (slot.isDirectional) {
149
+ // Normalize direction
150
+ const len = Math.sqrt(dx * dx + dy * dy);
151
+ slot.dirX = dx / len;
152
+ slot.dirY = dy / len;
153
+ } else {
154
+ slot.dirX = 0;
155
+ slot.dirY = 0;
156
+ }
157
+
158
+ state.active = true;
159
+ }
160
+
161
+ /**
162
+ * Add simple trauma to the first active slot, or create one with defaults.
163
+ * Backward-compatible with the original camera.addTrauma(amount) API. Zero-GC.
164
+ *
165
+ * @param {Object} state ShakeState
166
+ * @param {number} amount Trauma to add [0, 1]
167
+ */
168
+ export function addTraumaSimple(state, amount) {
169
+ // Try to find an existing default omni slot to stack onto.
170
+ // Preset/profile slots are NEVER stacked onto — they have parameters
171
+ // (freq, decay, etc.) that addTrauma's generic shake wouldn't match.
172
+ for (let i = 0; i < state.slotCount; i++) {
173
+ const s = state.slots[i];
174
+ if (s.active && s.isDefault) {
175
+ s.trauma = Math.min(1, s.trauma + amount);
176
+ return;
177
+ }
178
+ }
179
+
180
+ // No active omni slot — populate one inline. No intermediate object literal
181
+ // (which would otherwise allocate per call and violate the zero-GC contract).
182
+ const slot = acquireSlot(state);
183
+ slot.active = true;
184
+ slot.isDefault = true; // explicit profile/preset — not a generic trauma slot
185
+ slot.trauma = Math.min(1, amount);
186
+ slot.decay = 1.0;
187
+ slot.freq = 15;
188
+ slot.time = 0;
189
+ slot.maxOffset = 15;
190
+ slot.maxAngle = 0.05;
191
+ slot.dirX = 0;
192
+ slot.dirY = 0;
193
+ slot.isDirectional = false;
194
+ state.active = true;
195
+ }
196
+
197
+ /**
198
+ * Update all active shake slots: advance time, decay trauma.
199
+ * Called once per frame from camera.update().
200
+ *
201
+ * @param {Object} state ShakeState
202
+ * @param {number} dt Delta time in seconds
203
+ */
204
+ export function updateShake(state, dt) {
205
+ let anyActive = false;
206
+
207
+ for (let i = 0; i < state.slotCount; i++) {
208
+ const s = state.slots[i];
209
+ if (!s.active) continue;
210
+
211
+ s.time += dt;
212
+ s.trauma -= s.decay * dt;
213
+
214
+ if (s.trauma <= 0) {
215
+ s.trauma = 0;
216
+ s.active = false;
217
+ continue;
218
+ }
219
+
220
+ anyActive = true;
221
+ }
222
+
223
+ state.active = anyActive;
224
+ }
225
+
226
+ /**
227
+ * Compute the final shake offset and rotation by summing all active layers.
228
+ * Uses simplex noise for smooth, organic motion. Zero allocation.
229
+ *
230
+ * Called once per frame from camera.apply() AFTER updateShake().
231
+ *
232
+ * @param {Object} state ShakeState
233
+ */
234
+ export function computeShake(state) {
235
+ let totalOX = 0;
236
+ let totalOY = 0;
237
+ let totalAngle = 0;
238
+
239
+ for (let i = 0; i < state.slotCount; i++) {
240
+ const s = state.slots[i];
241
+ if (!s.active) continue;
242
+
243
+ // trauma² for perceptual scaling (small trauma = barely visible)
244
+ const shake = s.trauma * s.trauma;
245
+ const t = s.time * s.freq;
246
+
247
+ // Sample noise at 3 different offsets for X, Y, angle
248
+ const noiseBase = NOISE_SEED_OFFSET * (i * 3) + state.seedOffset;
249
+ const nx = simplex2(t, noiseBase); // [-1, 1]
250
+ const ny = simplex2(t, noiseBase + 1);
251
+ const na = simplex2(t, noiseBase + 2);
252
+
253
+ if (s.isDirectional) {
254
+ // Project shake onto the direction vector
255
+ const mag = s.maxOffset * shake * nx;
256
+ totalOX += mag * s.dirX;
257
+ totalOY += mag * s.dirY;
258
+ } else {
259
+ totalOX += s.maxOffset * shake * nx;
260
+ totalOY += s.maxOffset * shake * ny;
261
+ }
262
+
263
+ totalAngle += s.maxAngle * shake * na;
264
+ }
265
+
266
+ // Apply global scale
267
+ state.offsetX = Math.fround(totalOX * state.globalScale);
268
+ state.offsetY = Math.fround(totalOY * state.globalScale);
269
+ state.angle = Math.fround(totalAngle * state.globalScale);
270
+ }
271
+
272
+ /**
273
+ * Stop all active shakes immediately.
274
+ *
275
+ * @param {Object} state ShakeState
276
+ */
277
+ export function clearShakes(state) {
278
+ for (let i = 0; i < state.slotCount; i++) {
279
+ state.slots[i].active = false;
280
+ state.slots[i].trauma = 0;
281
+ }
282
+ state.active = false;
283
+ state.offsetX = 0;
284
+ state.offsetY = 0;
285
+ state.angle = 0;
286
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Shake Presets
3
+ *
4
+ * Frozen profile objects for common game shake scenarios.
5
+ * Each preset is a plain object matching the ShakeEngine profile shape.
6
+ * Developers can register custom presets via registerPreset().
7
+ *
8
+ * Zero dependencies. Pure data.
9
+ */
10
+
11
+ // ─────────────────────────────────────────────────────
12
+ // BUILT-IN PRESETS
13
+ // ─────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * EXPLOSION — Big boom. High trauma, low frequency for heavy sway,
17
+ * large offset, medium rotation. Slow decay for lingering feel.
18
+ */
19
+ export const EXPLOSION = Object.freeze({
20
+ trauma: 0.8,
21
+ freq: 12,
22
+ decay: 0.7,
23
+ maxOffset: 25,
24
+ maxAngle: 0.06,
25
+ dirX: 0,
26
+ dirY: 0,
27
+ });
28
+
29
+ /**
30
+ * EARTHQUAKE — Sustained rumble. Medium trauma, very low frequency
31
+ * for slow, heavy rolling. Large offset, minimal rotation. Slow decay.
32
+ */
33
+ export const EARTHQUAKE = Object.freeze({
34
+ trauma: 0.5,
35
+ freq: 6,
36
+ decay: 0.3,
37
+ maxOffset: 30,
38
+ maxAngle: 0.02,
39
+ dirX: 0,
40
+ dirY: 0,
41
+ });
42
+
43
+ /**
44
+ * RECOIL — Gun/weapon kickback. Directional (upward by default).
45
+ * Short, sharp burst with fast decay.
46
+ */
47
+ export const RECOIL = Object.freeze({
48
+ trauma: 0.5,
49
+ freq: 20,
50
+ decay: 2.5,
51
+ maxOffset: 12,
52
+ maxAngle: 0.02,
53
+ dirX: 0,
54
+ dirY: -1, // upward kick
55
+ });
56
+
57
+ /**
58
+ * IMPACT — Something hit the player/world. Sharp trauma spike,
59
+ * high frequency for a snappy jolt, fast decay.
60
+ */
61
+ export const IMPACT = Object.freeze({
62
+ trauma: 0.7,
63
+ freq: 25,
64
+ decay: 2.0,
65
+ maxOffset: 18,
66
+ maxAngle: 0.04,
67
+ dirX: 0,
68
+ dirY: 0,
69
+ });
70
+
71
+ /**
72
+ * LANDING — Player lands from a height. Vertical-only shake.
73
+ * Medium trauma, medium frequency, moderate decay.
74
+ */
75
+ export const LANDING = Object.freeze({
76
+ trauma: 0.4,
77
+ freq: 18,
78
+ decay: 1.5,
79
+ maxOffset: 10,
80
+ maxAngle: 0.01,
81
+ dirX: 0,
82
+ dirY: 1, // downward push
83
+ });
84
+
85
+ /**
86
+ * DAMAGE — Player takes a hit. Quick pulse, low offset,
87
+ * no rotation. Feels like a screen flash without the flash.
88
+ */
89
+ export const DAMAGE = Object.freeze({
90
+ trauma: 0.35,
91
+ freq: 22,
92
+ decay: 3.0,
93
+ maxOffset: 6,
94
+ maxAngle: 0,
95
+ dirX: 0,
96
+ dirY: 0,
97
+ });
98
+
99
+ /**
100
+ * RUMBLE — Continuous low-level vibration. Low trauma, high frequency
101
+ * for a "motor hum" feel. Very slow decay (lingers).
102
+ * Good for approaching boss, earthquake precursor, engine vibration.
103
+ */
104
+ export const RUMBLE = Object.freeze({
105
+ trauma: 0.2,
106
+ freq: 30,
107
+ decay: 0.15,
108
+ maxOffset: 3,
109
+ maxAngle: 0,
110
+ dirX: 0,
111
+ dirY: 0,
112
+ });
113
+
114
+ /**
115
+ * HEAVY_IMPACT — Boss stomp, meteor hit, critical attack.
116
+ * Maximum everything. The "oh no" shake.
117
+ */
118
+ export const HEAVY_IMPACT = Object.freeze({
119
+ trauma: 1.0,
120
+ freq: 10,
121
+ decay: 0.5,
122
+ maxOffset: 35,
123
+ maxAngle: 0.08,
124
+ dirX: 0,
125
+ dirY: 0,
126
+ });
127
+
128
+ // ─────────────────────────────────────────────────────
129
+ // PRESET REGISTRY
130
+ // ─────────────────────────────────────────────────────
131
+
132
+ /** @type {Object<string, Object>} */
133
+ const _registry = {
134
+ explosion: EXPLOSION,
135
+ earthquake: EARTHQUAKE,
136
+ recoil: RECOIL,
137
+ impact: IMPACT,
138
+ landing: LANDING,
139
+ damage: DAMAGE,
140
+ rumble: RUMBLE,
141
+ heavy_impact: HEAVY_IMPACT,
142
+ };
143
+
144
+ /**
145
+ * Get a preset by name.
146
+ *
147
+ * @param {string} name Preset name (case-insensitive)
148
+ * @returns {Object|null} Shake profile or null
149
+ */
150
+ export function getPreset(name) {
151
+ return _registry[name.toLowerCase()] || null;
152
+ }
153
+
154
+ /**
155
+ * Register a custom preset. Overwrites existing presets with the same name.
156
+ *
157
+ * @param {string} name Preset name
158
+ * @param {Object} profile Shake profile object
159
+ *
160
+ * @example
161
+ * registerPreset('sword_clash', {
162
+ * trauma: 0.3, freq: 28, decay: 3.0,
163
+ * maxOffset: 8, maxAngle: 0.03,
164
+ * dirX: 1, dirY: 0, // horizontal only
165
+ * });
166
+ */
167
+ export function registerPreset(name, profile) {
168
+ _registry[name.toLowerCase()] = Object.freeze({ ...profile });
169
+ }
170
+
171
+ /**
172
+ * List all registered preset names.
173
+ * @returns {string[]}
174
+ */
175
+ export function listPresets() {
176
+ return Object.keys(_registry);
177
+ }