@mpgd/game-runtime 0.4.0 → 0.6.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/README.md CHANGED
@@ -471,3 +471,43 @@ while preserving original key history and any live native lease. Foreign keys,
471
471
  purchase actions, pending results and grant-shaped results cannot be unlocked
472
472
  through this path. Positive grants continue through the authoritative ledger
473
473
  reconciliation port. This method never updates a disposed view or opens UI.
474
+
475
+ ## Scene-owned DOM banners
476
+
477
+ `@mpgd/game-runtime/dom` is an opt-in browser entrypoint. It consumes the canonical
478
+ `PlatformGateway.ads` banner methods and imports no platform SDK. The root and
479
+ other headless entrypoints retain their engine- and DOM-free declaration contracts.
480
+
481
+ ```ts
482
+ import { createInlineBannerManager } from '@mpgd/game-runtime/dom';
483
+
484
+ const banner = createInlineBannerManager({
485
+ gameRoot, ads: gateway.ads, enabled: bannerEnabled,
486
+ placementId: 'FOOTER', surfaceId: 'footer-ad', label: 'Advertisement',
487
+ layoutClassName: 'banner-layout', surfaceClassName: 'banner-surface',
488
+ stateDataKey: 'bannerState',
489
+ });
490
+ const release = banner.acquire({ onLayoutChange: resizeGame });
491
+ // Scene shutdown: release(); final game teardown: banner.destroy();
492
+ ```
493
+
494
+ Each mount adds an opaque unique suffix to the supplied surface namespace. The
495
+ provider receives that actual DOM ID. A newer acquisition replaces the parent
496
+ slot's previous owner (disposing a displaced manager), and a stale release or provider response only cleans up
497
+ its own native surface. Layout is `loading` until the provider confirms `mounted`;
498
+ style only the mounted state to reserve banner height. Unavailable/failed results
499
+ collapse the surface and rely on the provider's terminal cleanup contract. The
500
+ helper never selects placements, enables a target, supplies fallback advertising,
501
+ or grants rewards.
502
+
503
+ Page restoration renews the current acquisition, including hosts that do not use
504
+ BFCache. Lifecycle listeners and elements use the game root's own document/window
505
+ so embedded surfaces work independently. Release and destroy are idempotent;
506
+ layout observer errors cannot interrupt cleanup and can be observed through
507
+ `onObserverError`. The consumer owns CSS, banner dimensions, eligibility, and
508
+ scene shutdown wiring. Importing the module creates no DOM or lifecycle listeners.
509
+
510
+ ## Impact feedback
511
+
512
+ The opt-in `./impact` and `./phaser/impact` entries provide deterministic recipe
513
+ matching and scene-owned pooled rendering. See [configuration and gotchas](docs/impact-feedback.md).
@@ -1,4 +1,4 @@
1
- import type { PlatformEvidenceEnvelope, PlatformGateway, PurchaseResult } from '@mpgd/platform';
1
+ import type { PlatformEvidenceEnvelope, PlatformGateway, PlatformPurchasePresentationEvent, PurchaseResult } from '@mpgd/platform';
2
2
  import type { GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
3
3
  import { type AdPlacementInput, type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
4
4
  import { type ObserverErrorHandler } from '../observers.js';
@@ -44,11 +44,7 @@ export declare function createCoordinatedAdProvider(input: {
44
44
  readonly result: AdShowResult;
45
45
  }) => void | Promise<void>;
46
46
  }): CoordinatedAdProvider;
47
- export interface PurchasePresentationEvent {
48
- readonly idempotencyKey: string;
49
- readonly sequence: number;
50
- readonly state: 'open' | 'closed' | 'not-started' | 'unknown';
51
- }
47
+ export type PurchasePresentationEvent = PlatformPurchasePresentationEvent;
52
48
  export interface CoordinatedPlatformGateway extends PlatformGateway {
53
49
  dispose(): void;
54
50
  }
package/dist/ads/index.js CHANGED
@@ -475,7 +475,8 @@ export function createCoordinatedPlatformGateway(input) {
475
475
  }
476
476
  maybeDetach();
477
477
  }
478
- const unsubscribe = input.purchasePresentation?.subscribe((event) => {
478
+ const purchasePresentation = input.purchasePresentation ?? input.gateway.commerce.presentation;
479
+ const unsubscribe = purchasePresentation?.subscribe((event) => {
479
480
  observe(() => {
480
481
  const flight = purchases.get(event.idempotencyKey);
481
482
  if (flight === undefined || !flight.called || flight.closed) {
@@ -0,0 +1 @@
1
+ export { createInlineBannerManager, mountInlineBanner, type InlineBannerAds, type InlineBannerOptions, type InlineBannerController, type InlineBannerManager, } from './inline-banner.js';
@@ -0,0 +1 @@
1
+ export { createInlineBannerManager, mountInlineBanner, } from './inline-banner.js';
@@ -0,0 +1,31 @@
1
+ import type { PlatformGateway } from '@mpgd/platform';
2
+ import { type ObserverErrorHandler } from '../observers.js';
3
+ export type InlineBannerAds = Pick<PlatformGateway['ads'], 'mountBanner' | 'unmountBanner'>;
4
+ export interface InlineBannerOptions {
5
+ readonly gameRoot: HTMLElement;
6
+ readonly ads: InlineBannerAds;
7
+ readonly enabled: boolean;
8
+ readonly placementId: string;
9
+ /** Game-owned namespace; each mount appends a unique opaque suffix. */
10
+ readonly surfaceId: string;
11
+ readonly label: string;
12
+ readonly layoutClassName: string;
13
+ readonly surfaceClassName: string;
14
+ readonly stateDataKey: string;
15
+ readonly onObserverError?: ObserverErrorHandler;
16
+ }
17
+ export interface InlineBannerController {
18
+ readonly destroy: () => void;
19
+ }
20
+ export interface InlineBannerManager {
21
+ readonly acquire: (input?: {
22
+ readonly onLayoutChange?: () => void;
23
+ }) => () => void;
24
+ readonly destroy: () => void;
25
+ }
26
+ /** Scene-owned acquisition over the canonical gateway, with page-restore renewal. */
27
+ export declare function createInlineBannerManager(input: InlineBannerOptions): InlineBannerManager;
28
+ /** Reserve layout only after provider confirmation; asynchronous cleanup keeps its own ID. */
29
+ export declare function mountInlineBanner(input: InlineBannerOptions & {
30
+ readonly onLayoutChange?: () => void;
31
+ }): InlineBannerController;
@@ -0,0 +1,221 @@
1
+ import { observe } from '../observers.js';
2
+ // A parent represents one layout slot. A newer mount releases the previous owner.
3
+ const parentOwners = new WeakMap();
4
+ const parentManagers = new WeakMap();
5
+ let nextSurfaceSequence = 0;
6
+ /** Scene-owned acquisition over the canonical gateway, with page-restore renewal. */
7
+ export function createInlineBannerManager(input) {
8
+ assertOptions(input);
9
+ let active = null;
10
+ let destroyed = false;
11
+ let acquisitionRevision = 0;
12
+ let restorePending = false;
13
+ let ownedParent = null;
14
+ const lifecycle = input.gameRoot.ownerDocument.defaultView;
15
+ const mount = (owner, onLayoutChange) => {
16
+ const controller = mountBannerSurface({
17
+ ...input,
18
+ ...(onLayoutChange === undefined ? {} : { onLayoutChange }),
19
+ }, destroyManager);
20
+ if (destroyed || owner.released || active !== null) {
21
+ controller.destroy();
22
+ return;
23
+ }
24
+ active = {
25
+ owner,
26
+ controller,
27
+ ...(onLayoutChange === undefined ? {} : { onLayoutChange }),
28
+ };
29
+ };
30
+ const onHide = () => {
31
+ restorePending = true;
32
+ };
33
+ const onShow = () => {
34
+ if (!restorePending) {
35
+ return;
36
+ }
37
+ restorePending = false;
38
+ if (destroyed || active === null) {
39
+ return;
40
+ }
41
+ const revision = acquisitionRevision;
42
+ const previous = active;
43
+ active = null;
44
+ previous.controller.destroy();
45
+ if (destroyed || previous.owner.released || acquisitionRevision !== revision) {
46
+ return;
47
+ }
48
+ mount(previous.owner, previous.onLayoutChange);
49
+ };
50
+ lifecycle?.addEventListener('pagehide', onHide);
51
+ lifecycle?.addEventListener('pageshow', onShow);
52
+ return {
53
+ acquire(acquisition = {}) {
54
+ if (destroyed) {
55
+ return () => { };
56
+ }
57
+ const revision = ++acquisitionRevision;
58
+ const parent = input.gameRoot.parentElement;
59
+ if (ownedParent !== null && ownedParent !== parent && parentManagers.get(ownedParent) === destroyManager) {
60
+ parentManagers.delete(ownedParent);
61
+ }
62
+ ownedParent = parent;
63
+ if (parent !== null) {
64
+ const previousManager = parentManagers.get(parent);
65
+ parentManagers.set(parent, destroyManager);
66
+ if (previousManager !== destroyManager) {
67
+ previousManager?.();
68
+ }
69
+ }
70
+ if (destroyed || acquisitionRevision !== revision) {
71
+ return () => { };
72
+ }
73
+ const previous = active;
74
+ active = null;
75
+ previous?.controller.destroy();
76
+ if (destroyed || acquisitionRevision !== revision) {
77
+ return () => { };
78
+ }
79
+ const owner = { released: false };
80
+ mount(owner, acquisition.onLayoutChange);
81
+ let released = false;
82
+ return () => {
83
+ if (released) {
84
+ return;
85
+ }
86
+ released = true;
87
+ owner.released = true;
88
+ if (active?.owner === owner) {
89
+ const previous = active;
90
+ active = null;
91
+ previous.controller.destroy();
92
+ }
93
+ };
94
+ },
95
+ destroy: destroyManager,
96
+ };
97
+ function destroyManager() {
98
+ if (destroyed) {
99
+ return;
100
+ }
101
+ destroyed = true;
102
+ lifecycle?.removeEventListener('pagehide', onHide);
103
+ lifecycle?.removeEventListener('pageshow', onShow);
104
+ const previous = active;
105
+ active = null;
106
+ previous?.controller.destroy();
107
+ if (ownedParent !== null && parentManagers.get(ownedParent) === destroyManager) {
108
+ parentManagers.delete(ownedParent);
109
+ }
110
+ ownedParent = null;
111
+ }
112
+ }
113
+ /** Reserve layout only after provider confirmation; asynchronous cleanup keeps its own ID. */
114
+ export function mountInlineBanner(input) {
115
+ return mountBannerSurface(input);
116
+ }
117
+ function mountBannerSurface(input, managerOwner) {
118
+ assertOptions(input);
119
+ const parent = input.gameRoot.parentElement;
120
+ const mountBanner = input.ads.mountBanner?.bind(input.ads);
121
+ const unmountBanner = input.ads.unmountBanner?.bind(input.ads);
122
+ if (!input.enabled || mountBanner === undefined || parent === null) {
123
+ return { destroy() { } };
124
+ }
125
+ const layoutParent = parent;
126
+ const doc = input.gameRoot.ownerDocument;
127
+ const random = doc.defaultView?.crypto ?? globalThis.crypto;
128
+ if (random === undefined) {
129
+ throw new Error('Inline banner surfaces require a crypto random source.');
130
+ }
131
+ const suffix = Array.from(random.getRandomValues(new Uint32Array(4)), (value) => value.toString(16).padStart(8, '0')).join('');
132
+ nextSurfaceSequence += 1;
133
+ const surfaceId = `${input.surfaceId}-${suffix}-${nextSurfaceSequence}`;
134
+ const surface = doc.createElement('aside');
135
+ surface.id = surfaceId;
136
+ surface.className = input.surfaceClassName;
137
+ surface.setAttribute('aria-label', input.label);
138
+ surface.dataset.adPlacement = input.placementId;
139
+ let destroyed = false;
140
+ let promise = null;
141
+ const notifyLayout = () => observe(() => input.onLayoutChange?.(), input.onObserverError);
142
+ const onHide = (event) => {
143
+ if (!event.persisted) {
144
+ destroy();
145
+ }
146
+ };
147
+ const lifecycle = doc.defaultView;
148
+ function destroy() {
149
+ if (destroyed) {
150
+ return;
151
+ }
152
+ destroyed = true;
153
+ surface.remove();
154
+ if (parentOwners.get(layoutParent) === destroy) {
155
+ parentOwners.delete(layoutParent);
156
+ layoutParent.classList.remove(input.layoutClassName);
157
+ delete layoutParent.dataset[input.stateDataKey];
158
+ }
159
+ lifecycle?.removeEventListener('pagehide', onHide);
160
+ if (unmountBanner !== undefined && promise !== null) {
161
+ void promise.then((result) => result.status === 'mounted' ? unmountBanner({ surfaceId }) : undefined, () => undefined).catch((error) => observe(() => { throw error; }, input.onObserverError));
162
+ }
163
+ notifyLayout();
164
+ }
165
+ const manager = parentManagers.get(layoutParent);
166
+ if (manager !== undefined && manager !== managerOwner) {
167
+ manager();
168
+ }
169
+ const previousOwner = parentOwners.get(layoutParent);
170
+ parentOwners.set(layoutParent, destroy);
171
+ previousOwner?.();
172
+ if (destroyed) {
173
+ return { destroy };
174
+ }
175
+ layoutParent.classList.add(input.layoutClassName);
176
+ layoutParent.dataset[input.stateDataKey] = 'loading';
177
+ layoutParent.append(surface);
178
+ lifecycle?.addEventListener('pagehide', onHide);
179
+ try {
180
+ const mounted = Promise.resolve(mountBanner({ placementId: input.placementId, surfaceId }));
181
+ promise = mounted;
182
+ void mounted.then((result) => {
183
+ if (destroyed) {
184
+ return;
185
+ }
186
+ if (result.status === 'mounted') {
187
+ layoutParent.dataset[input.stateDataKey] = 'mounted';
188
+ notifyLayout();
189
+ }
190
+ else {
191
+ destroy();
192
+ }
193
+ }, () => destroy());
194
+ }
195
+ catch {
196
+ destroy();
197
+ }
198
+ return { destroy };
199
+ }
200
+ function assertOptions(input) {
201
+ for (const name of [
202
+ 'placementId',
203
+ 'surfaceId',
204
+ 'label',
205
+ 'layoutClassName',
206
+ 'surfaceClassName',
207
+ 'stateDataKey',
208
+ ]) {
209
+ const value = input[name];
210
+ if (typeof value !== 'string' || !value.trim() || value.length > (name === 'surfaceId' ? 128 : 256)
211
+ || /[\u0000-\u001f\u007f]/u.test(value)) {
212
+ throw new TypeError(`Inline banner ${name} is invalid.`);
213
+ }
214
+ }
215
+ if (/\s/u.test(input.layoutClassName) || !/^[a-z][a-zA-Z0-9]*$/u.test(input.stateDataKey)) {
216
+ throw new TypeError('Inline banner layoutClassName must be a CSS token and stateDataKey a camel-case key.');
217
+ }
218
+ if (typeof input.enabled !== 'boolean') {
219
+ throw new TypeError('Inline banner enabled must be a boolean.');
220
+ }
221
+ }
@@ -27,6 +27,9 @@ export interface GamePlatformRuntime<T extends PlatformGateway = PlatformGateway
27
27
  readonly services: S;
28
28
  readonly actions?: GameActionCoordinator;
29
29
  createGameplayScope(): GameGameplayScope;
30
+ setLoadingProgress(progress: number): void;
31
+ /** Complete host loading before opening a play scene. Rejected starts remain blocked. */
32
+ completeLoading(): Promise<void>;
30
33
  /** Reconcile existing journal entries only; this method never opens platform UI. */
31
34
  reconcile(): Promise<void>;
32
35
  /** Game teardown; native observers and application-owned late recovery may continue. */
@@ -44,4 +47,6 @@ export declare function createGamePlatformRuntime<T extends PlatformGateway, S e
44
47
  subscribe(listener: (event: PurchasePresentationEvent) => void): () => void;
45
48
  };
46
49
  readonly onObserverError?: ObserverErrorHandler;
50
+ /** Must complete synchronously; asynchronous uploads belong at earlier checkpoints. */
51
+ readonly onExit?: () => void;
47
52
  }): GamePlatformRuntime<T, S>;
@@ -39,6 +39,47 @@ export function createGamePlatformRuntime(input) {
39
39
  let actions;
40
40
  let unsubscribeResume;
41
41
  let disposed = false;
42
+ let unsubscribeExit;
43
+ let startupBlock = input.gateway.gameLoading === undefined
44
+ ? undefined
45
+ : execution.acquireBlock({
46
+ reason: 'game-runtime:loading',
47
+ channels: ['simulation', 'gameplay-input', 'audio'],
48
+ });
49
+ let loadingCompletion;
50
+ const setLoadingProgress = (progress) => {
51
+ if (!disposed && loadingCompletion === undefined) {
52
+ observe(() => gateway.gameLoading?.setProgress(Math.max(0, Math.min(100, Number(progress) || 0))), input.onObserverError);
53
+ }
54
+ };
55
+ const completeLoading = () => {
56
+ if (disposed) {
57
+ return Promise.reject(new Error('Game runtime is disposed.'));
58
+ }
59
+ if (loadingCompletion !== undefined) {
60
+ return loadingCompletion;
61
+ }
62
+ const attempt = Promise.resolve().then(async () => {
63
+ if (disposed) {
64
+ throw new Error('Game runtime is disposed.');
65
+ }
66
+ gateway.gameLoading?.setProgress(100);
67
+ await gateway.gameLoading?.complete();
68
+ if (disposed) {
69
+ throw new Error('Game runtime was disposed during loading.');
70
+ }
71
+ const owned = startupBlock;
72
+ startupBlock = undefined;
73
+ owned?.release();
74
+ });
75
+ loadingCompletion = attempt;
76
+ void attempt.catch(() => {
77
+ if (loadingCompletion === attempt) {
78
+ loadingCompletion = undefined;
79
+ }
80
+ });
81
+ return attempt;
82
+ };
42
83
  let hostAudio;
43
84
  let unsubscribeHostAudio;
44
85
  const gameplayScopes = new Map();
@@ -108,6 +149,7 @@ export function createGamePlatformRuntime(input) {
108
149
  }, input.onObserverError);
109
150
  }
110
151
  unsubscribeActivity?.();
152
+ unsubscribeExit?.();
111
153
  gameplayScopes.clear();
112
154
  execution.destroy();
113
155
  if (unsubscribeHostAudio !== undefined) {
@@ -156,6 +198,14 @@ export function createGamePlatformRuntime(input) {
156
198
  ...(input.onObserverError === undefined ? {} : { onError: input.onObserverError }),
157
199
  });
158
200
  unsubscribeResume = gateway.lifecycle.onResume(() => { observe(reconcile, input.onObserverError); });
201
+ unsubscribeExit = gateway.lifecycle.onExit?.(() => {
202
+ observe(() => input.onExit?.(), input.onObserverError);
203
+ dispose();
204
+ });
205
+ if (disposed) {
206
+ unsubscribeExit?.();
207
+ throw new Error('Game exited during runtime initialization.');
208
+ }
159
209
  const settings = gateway.gameSettings;
160
210
  if (settings !== undefined) {
161
211
  const updateMute = (muted) => {
@@ -182,6 +232,8 @@ export function createGamePlatformRuntime(input) {
182
232
  presentation,
183
233
  services,
184
234
  createGameplayScope,
235
+ setLoadingProgress,
236
+ completeLoading,
185
237
  ...(actions === undefined ? {} : { actions }),
186
238
  reconcile,
187
239
  dispose,
@@ -0,0 +1,85 @@
1
+ export interface ImpactParticipant {
2
+ readonly id?: number | string;
3
+ readonly kind: string;
4
+ readonly tags?: readonly string[];
5
+ }
6
+ export interface ImpactContact {
7
+ readonly atMs: number;
8
+ readonly intensity: number;
9
+ readonly normalX: number;
10
+ readonly normalY: number;
11
+ readonly source: ImpactParticipant;
12
+ readonly target: ImpactParticipant;
13
+ readonly x: number;
14
+ readonly y: number;
15
+ }
16
+ export interface CreateImpactContactInput {
17
+ readonly atMs: number;
18
+ readonly intensity?: number;
19
+ readonly normalX?: number;
20
+ readonly normalY?: number;
21
+ readonly source: ImpactParticipant;
22
+ readonly target: ImpactParticipant;
23
+ readonly x: number;
24
+ readonly y: number;
25
+ }
26
+ export interface ImpactParticipantSelector {
27
+ readonly kinds?: readonly string[];
28
+ readonly tags?: readonly string[];
29
+ }
30
+ export interface ImpactFeedbackRule {
31
+ readonly id: string;
32
+ readonly priority?: number;
33
+ readonly recipeId: string;
34
+ readonly source?: ImpactParticipantSelector;
35
+ readonly target?: ImpactParticipantSelector;
36
+ }
37
+ export interface ImpactRingRecipe {
38
+ readonly color: number;
39
+ readonly endAlpha?: number;
40
+ readonly endRadius: number;
41
+ readonly lineWidth?: number;
42
+ readonly startAlpha?: number;
43
+ readonly startRadius: number;
44
+ }
45
+ export interface ImpactSparkRecipe {
46
+ /** Local geometry, oriented along the contact normal and the authored spread. */
47
+ readonly shape?: 'line' | 'chevron' | 'bracket' | 'cross';
48
+ readonly color: number;
49
+ readonly count: number;
50
+ readonly endDistance: number;
51
+ readonly length: number;
52
+ readonly spreadRadians: number;
53
+ readonly startDistance?: number;
54
+ readonly width: number;
55
+ }
56
+ export interface ImpactFlashRecipe {
57
+ readonly color: number;
58
+ readonly durationMs: number;
59
+ }
60
+ export interface ImpactFeedbackRecipe {
61
+ readonly durationMs: number;
62
+ readonly flash?: ImpactFlashRecipe;
63
+ readonly id: string;
64
+ readonly ring?: ImpactRingRecipe;
65
+ readonly sparks?: ImpactSparkRecipe;
66
+ }
67
+ export interface ImpactFeedbackCatalogInput {
68
+ readonly recipes: readonly ImpactFeedbackRecipe[];
69
+ readonly rules: readonly ImpactFeedbackRule[];
70
+ }
71
+ export interface ImpactFeedbackCatalog {
72
+ readonly recipes: ReadonlyMap<string, ImpactFeedbackRecipe>;
73
+ readonly rules: readonly ImpactFeedbackRule[];
74
+ }
75
+ export interface ImpactFeedbackCarrier {
76
+ readonly impact: ImpactContact;
77
+ }
78
+ export declare const MAX_IMPACT_RECIPES = 256;
79
+ export declare const MAX_IMPACT_RULES = 1024;
80
+ export declare function createImpactContact(input: CreateImpactContactInput): ImpactContact;
81
+ export declare function defineImpactFeedbackCatalog(input: ImpactFeedbackCatalogInput): ImpactFeedbackCatalog;
82
+ export declare function resolveImpactFeedbackRecipe(catalog: ImpactFeedbackCatalog, contact: ImpactContact): ImpactFeedbackRecipe | undefined;
83
+ export declare function hasImpactFeedback(value: unknown): value is ImpactFeedbackCarrier;
84
+ export declare function assertImpactFeedbackRecipe(recipe: ImpactFeedbackRecipe): void;
85
+ export declare function assertImpactContact(contact: ImpactContact): void;
@@ -0,0 +1,229 @@
1
+ export const MAX_IMPACT_RECIPES = 256;
2
+ export const MAX_IMPACT_RULES = 1_024;
3
+ const sparkShapes = ['line', 'chevron', 'bracket', 'cross'];
4
+ const DEFAULT_NORMAL_X = 0;
5
+ const DEFAULT_NORMAL_Y = -1;
6
+ export function createImpactContact(input) {
7
+ assertNonNegative('atMs', input.atMs);
8
+ assertParticipant(input.source);
9
+ assertParticipant(input.target);
10
+ assertFinite('x', input.x);
11
+ assertFinite('y', input.y);
12
+ const intensity = input.intensity ?? 1;
13
+ assertFinite('intensity', intensity);
14
+ if (intensity < 0) {
15
+ throw new Error('Impact intensity must be greater than or equal to zero.');
16
+ }
17
+ const normalX = input.normalX ?? DEFAULT_NORMAL_X;
18
+ const normalY = input.normalY ?? DEFAULT_NORMAL_Y;
19
+ assertFinite('normalX', normalX);
20
+ assertFinite('normalY', normalY);
21
+ const scale = Math.max(Math.abs(normalX), Math.abs(normalY));
22
+ const length = scale === 0 ? 0 : Math.hypot(normalX / scale, normalY / scale);
23
+ return {
24
+ atMs: input.atMs,
25
+ intensity,
26
+ normalX: length > 0 ? (normalX / scale) / length : DEFAULT_NORMAL_X,
27
+ normalY: length > 0 ? (normalY / scale) / length : DEFAULT_NORMAL_Y,
28
+ source: input.source,
29
+ target: input.target,
30
+ x: input.x,
31
+ y: input.y,
32
+ };
33
+ }
34
+ export function defineImpactFeedbackCatalog(input) {
35
+ if (!Array.isArray(input.recipes) || input.recipes.length > MAX_IMPACT_RECIPES
36
+ || !Array.isArray(input.rules) || input.rules.length > MAX_IMPACT_RULES) {
37
+ throw new Error('Impact catalog exceeds recipe or rule bounds.');
38
+ }
39
+ const recipes = new Map();
40
+ for (const recipe of input.recipes) {
41
+ assertIdentifier('recipe', recipe.id);
42
+ if (recipes.has(recipe.id)) {
43
+ throw new Error(`Duplicate impact feedback recipe id: ${recipe.id}`);
44
+ }
45
+ assertImpactFeedbackRecipe(recipe);
46
+ recipes.set(recipe.id, Object.freeze({
47
+ ...recipe,
48
+ ...(recipe.ring === undefined ? {} : { ring: Object.freeze({ ...recipe.ring }) }),
49
+ ...(recipe.sparks === undefined ? {} : { sparks: Object.freeze({ ...recipe.sparks }) }),
50
+ ...(recipe.flash === undefined ? {} : { flash: Object.freeze({ ...recipe.flash }) }),
51
+ }));
52
+ }
53
+ const ruleIds = new Set();
54
+ for (const rule of input.rules) {
55
+ assertIdentifier('rule', rule.id);
56
+ assertFinite('rule.priority', rule.priority ?? 0);
57
+ assertSelector(rule.source);
58
+ assertSelector(rule.target);
59
+ if (ruleIds.has(rule.id)) {
60
+ throw new Error(`Duplicate impact feedback rule id: ${rule.id}`);
61
+ }
62
+ if (!recipes.has(rule.recipeId)) {
63
+ throw new Error(`Impact feedback rule ${rule.id} references unknown recipe: ${rule.recipeId}`);
64
+ }
65
+ ruleIds.add(rule.id);
66
+ }
67
+ const rules = input.rules
68
+ .map((rule, index) => ({ index, rule }))
69
+ .sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index)
70
+ .map(({ rule }) => Object.freeze({
71
+ ...rule,
72
+ ...(rule.source === undefined ? {} : { source: cloneSelector(rule.source) }),
73
+ ...(rule.target === undefined ? {} : { target: cloneSelector(rule.target) }),
74
+ }));
75
+ return Object.freeze({ recipes, rules: Object.freeze(rules) });
76
+ }
77
+ export function resolveImpactFeedbackRecipe(catalog, contact) {
78
+ for (const rule of catalog.rules) {
79
+ if (participantMatches(contact.source, rule.source)
80
+ && participantMatches(contact.target, rule.target)) {
81
+ return catalog.recipes.get(rule.recipeId);
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ export function hasImpactFeedback(value) {
87
+ if (typeof value !== 'object' || value === null || !('impact' in value)) {
88
+ return false;
89
+ }
90
+ try {
91
+ assertImpactContact(value.impact);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function participantMatches(participant, selector) {
99
+ if (selector === undefined) {
100
+ return true;
101
+ }
102
+ if (selector.kinds !== undefined && !selector.kinds.includes(participant.kind)) {
103
+ return false;
104
+ }
105
+ if (selector.tags === undefined) {
106
+ return true;
107
+ }
108
+ const tags = participant.tags ?? [];
109
+ return selector.tags.every((tag) => tags.includes(tag));
110
+ }
111
+ export function assertImpactFeedbackRecipe(recipe) {
112
+ assertPositive('durationMs', recipe.durationMs);
113
+ if (recipe.ring !== undefined) {
114
+ assertColor('ring.color', recipe.ring.color);
115
+ assertNonNegative('ring.startRadius', recipe.ring.startRadius);
116
+ assertNonNegative('ring.endRadius', recipe.ring.endRadius);
117
+ assertNonNegative('ring.lineWidth', recipe.ring.lineWidth ?? 2);
118
+ assertUnitInterval('ring.startAlpha', recipe.ring.startAlpha ?? 1);
119
+ assertUnitInterval('ring.endAlpha', recipe.ring.endAlpha ?? 0);
120
+ }
121
+ if (recipe.sparks !== undefined) {
122
+ if (recipe.sparks.shape !== undefined
123
+ && !sparkShapes.includes(recipe.sparks.shape)) {
124
+ throw new Error('Impact recipe sparks.shape must be line, chevron, bracket or cross.');
125
+ }
126
+ assertColor('sparks.color', recipe.sparks.color);
127
+ if (!Number.isInteger(recipe.sparks.count) || recipe.sparks.count < 0 || recipe.sparks.count > 64) {
128
+ throw new Error('Impact recipe sparks.count must be a non-negative integer.');
129
+ }
130
+ assertNonNegative('sparks.startDistance', recipe.sparks.startDistance ?? 0);
131
+ assertNonNegative('sparks.endDistance', recipe.sparks.endDistance);
132
+ assertPositive('sparks.length', recipe.sparks.length);
133
+ assertPositive('sparks.width', recipe.sparks.width);
134
+ assertFinite('sparks.spreadRadians', recipe.sparks.spreadRadians);
135
+ }
136
+ if (recipe.flash !== undefined) {
137
+ assertColor('flash.color', recipe.flash.color);
138
+ assertPositive('flash.durationMs', recipe.flash.durationMs);
139
+ }
140
+ if (recipe.ring === undefined && recipe.sparks === undefined && recipe.flash === undefined) {
141
+ throw new Error(`Impact feedback recipe ${recipe.id} must define at least one effect.`);
142
+ }
143
+ }
144
+ function assertIdentifier(kind, value) {
145
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > 256) {
146
+ throw new Error(`Impact feedback ${kind} id must not be empty.`);
147
+ }
148
+ }
149
+ function assertColor(name, value) {
150
+ if (!Number.isInteger(value) || value < 0 || value > 0xffffff) {
151
+ throw new Error(`Impact recipe ${name} must be an integer between 0x000000 and 0xffffff.`);
152
+ }
153
+ }
154
+ function assertFinite(name, value) {
155
+ if (!Number.isFinite(value)) {
156
+ throw new Error(`Impact ${name} must be finite.`);
157
+ }
158
+ }
159
+ function assertNonNegative(name, value) {
160
+ assertFinite(name, value);
161
+ if (value < 0) {
162
+ throw new Error(`Impact recipe ${name} must be greater than or equal to zero.`);
163
+ }
164
+ }
165
+ function assertPositive(name, value) {
166
+ assertFinite(name, value);
167
+ if (value <= 0) {
168
+ throw new Error(`Impact recipe ${name} must be greater than zero.`);
169
+ }
170
+ }
171
+ function assertUnitInterval(name, value) {
172
+ assertFinite(name, value);
173
+ if (value < 0 || value > 1) {
174
+ throw new Error(`Impact recipe ${name} must be between zero and one.`);
175
+ }
176
+ }
177
+ export function assertImpactContact(contact) {
178
+ if (typeof contact !== 'object' || contact === null) {
179
+ throw new Error('Impact contact must be an object.');
180
+ }
181
+ assertNonNegative('atMs', contact.atMs);
182
+ assertNonNegative('intensity', contact.intensity);
183
+ assertFinite('x', contact.x);
184
+ assertFinite('y', contact.y);
185
+ assertFinite('normalX', contact.normalX);
186
+ assertFinite('normalY', contact.normalY);
187
+ assertParticipant(contact.source);
188
+ assertParticipant(contact.target);
189
+ }
190
+ function assertParticipant(participant) {
191
+ if (typeof participant !== 'object' || participant === null) {
192
+ throw new Error('Impact participant must be an object.');
193
+ }
194
+ assertIdentifier('participant kind', participant.kind);
195
+ if (participant.id !== undefined && typeof participant.id !== 'string' && typeof participant.id !== 'number') {
196
+ throw new Error('Impact participant id must be a string or number.');
197
+ }
198
+ if (typeof participant.id === 'number') {
199
+ assertFinite('participant id', participant.id);
200
+ }
201
+ assertLabels(participant.tags);
202
+ }
203
+ function assertLabels(labels) {
204
+ if (labels === undefined) {
205
+ return;
206
+ }
207
+ if (!Array.isArray(labels) || labels.length > 64) {
208
+ throw new Error('Impact selectors and tags are limited to 64 labels.');
209
+ }
210
+ for (const label of labels) {
211
+ assertIdentifier('label', label);
212
+ }
213
+ }
214
+ function assertSelector(selector) {
215
+ if (selector === undefined) {
216
+ return;
217
+ }
218
+ if (typeof selector !== 'object' || selector === null) {
219
+ throw new Error('Impact selector must be an object.');
220
+ }
221
+ assertLabels(selector.kinds);
222
+ assertLabels(selector.tags);
223
+ }
224
+ function cloneSelector(selector) {
225
+ return Object.freeze({
226
+ ...(selector.kinds === undefined ? {} : { kinds: Object.freeze([...selector.kinds]) }),
227
+ ...(selector.tags === undefined ? {} : { tags: Object.freeze([...selector.tags]) }),
228
+ });
229
+ }
@@ -0,0 +1,50 @@
1
+ import type Phaser from 'phaser';
2
+ import { type ImpactContact, type ImpactFeedbackRecipe } from '../impact/index.js';
3
+ export interface PhaserImpactFeedbackPoolOptions {
4
+ readonly capacity?: number;
5
+ readonly depth?: number;
6
+ readonly maxSparksPerEffect?: number;
7
+ readonly rasterRings?: {
8
+ readonly textureKey: string;
9
+ /** A static frame or a registered strip sampled using the existing effect clock. */
10
+ readonly recipeFrames: Readonly<Record<string, string | readonly string[]>>;
11
+ };
12
+ }
13
+ export interface PhaserImpactFeedbackPoolDiagnostics {
14
+ readonly activeEffects: number;
15
+ readonly availableEffects: number;
16
+ readonly capacity: number;
17
+ readonly emittedEffects: number;
18
+ readonly peakActiveEffects: number;
19
+ readonly recycledEffects: number;
20
+ }
21
+ export declare const MAX_IMPACT_POOL_CAPACITY = 1024;
22
+ export declare const MAX_IMPACT_POOL_SPARKS = 64;
23
+ export declare class PhaserImpactFeedbackPool {
24
+ private readonly scene;
25
+ private activeSparkEffects;
26
+ private readonly activeSlots;
27
+ private destroyed;
28
+ private emittedEffects;
29
+ private flashEndsAtMs;
30
+ private nextSlotIndex;
31
+ private peakActiveEffects;
32
+ private recycledEffects;
33
+ private readonly slots;
34
+ private readonly availableSlots;
35
+ private readonly sparkBatch;
36
+ private sparkBatchVisible;
37
+ private readonly rasterRings;
38
+ constructor(scene: Phaser.Scene, options?: PhaserImpactFeedbackPoolOptions);
39
+ emit(contact: ImpactContact, recipe: ImpactFeedbackRecipe): void;
40
+ update(nowMs: number): void;
41
+ diagnostics(): PhaserImpactFeedbackPoolDiagnostics;
42
+ /** Release every live effect without destroying the prewarmed pool. */
43
+ clear(): void;
44
+ readonly destroy: () => void;
45
+ private activateRecipe;
46
+ private updateRecipe;
47
+ private renderSparkBatch;
48
+ private hideSparkBatch;
49
+ private release;
50
+ }
@@ -0,0 +1,406 @@
1
+ import { assertImpactContact, assertImpactFeedbackRecipe, } from '../impact/index.js';
2
+ export const MAX_IMPACT_POOL_CAPACITY = 1_024;
3
+ export const MAX_IMPACT_POOL_SPARKS = 64;
4
+ const DEFAULT_CAPACITY = 48;
5
+ const DEFAULT_MAX_SPARKS = 6;
6
+ const DEFAULT_DEPTH = 40;
7
+ export class PhaserImpactFeedbackPool {
8
+ scene;
9
+ activeSparkEffects = 0;
10
+ activeSlots = [];
11
+ destroyed = false;
12
+ emittedEffects = 0;
13
+ flashEndsAtMs = Number.NEGATIVE_INFINITY;
14
+ nextSlotIndex = 0;
15
+ peakActiveEffects = 0;
16
+ recycledEffects = 0;
17
+ slots;
18
+ availableSlots;
19
+ sparkBatch;
20
+ sparkBatchVisible = false;
21
+ rasterRings;
22
+ constructor(scene, options = {}) {
23
+ this.scene = scene;
24
+ const capacity = requirePositiveInteger('capacity', options.capacity ?? DEFAULT_CAPACITY);
25
+ const maxSparks = requireNonNegativeInteger('maxSparksPerEffect', options.maxSparksPerEffect ?? DEFAULT_MAX_SPARKS);
26
+ const depth = options.depth ?? DEFAULT_DEPTH;
27
+ if (!Number.isFinite(depth)) {
28
+ throw new Error('Impact depth must be finite.');
29
+ }
30
+ this.rasterRings = copyRasterRings(options.rasterRings);
31
+ if (this.rasterRings !== undefined) {
32
+ if (Object.keys(this.rasterRings.recipeFrames).length > 256) {
33
+ throw new Error('Impact art mappings are limited to 256 recipes.');
34
+ }
35
+ const texture = scene.textures.get(this.rasterRings.textureKey);
36
+ for (const entry of Object.values(this.rasterRings.recipeFrames)) {
37
+ const frames = typeof entry === 'string' ? [entry] : entry;
38
+ if (frames.length === 0 || frames.length > 64) {
39
+ throw new Error('Impact art sequence must not be empty.');
40
+ }
41
+ for (const frame of frames) {
42
+ if (!texture.has(frame)) {
43
+ throw new Error(`Impact art frame is unavailable: ${frame}`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ this.slots = Array.from({ length: capacity }, (_, index) => {
49
+ const ring = scene.add
50
+ .circle(0, 0, 1, 0xffffff, 0)
51
+ .setStrokeStyle(2, 0xffffff, 1)
52
+ .setDepth(depth)
53
+ .setActive(false)
54
+ .setVisible(false);
55
+ return {
56
+ index,
57
+ active: false,
58
+ activeIndex: -1,
59
+ endsAtMs: 0,
60
+ intensity: 1,
61
+ ring,
62
+ rasterRing: options.rasterRings === undefined ? undefined : scene.add
63
+ .image(0, 0, options.rasterRings.textureKey)
64
+ .setDepth(depth).setActive(false).setVisible(false),
65
+ rasterRingVisible: false,
66
+ rasterFrames: undefined,
67
+ rasterFrameIndex: 0,
68
+ ringAlphaDelta: 0,
69
+ ringRadiusDelta: 0,
70
+ ringStartAlpha: 0,
71
+ ringStartRadius: 0,
72
+ ringVisible: false,
73
+ sparkAlpha: 0,
74
+ sparkColor: 0xffffff,
75
+ sparkCount: 0,
76
+ sparkDistance: 0,
77
+ sparkDistanceDelta: 0,
78
+ sparkDirectionX: Array.from({ length: maxSparks }, () => 0),
79
+ sparkDirectionY: Array.from({ length: maxSparks }, () => -1),
80
+ sparkLength: 0,
81
+ sparkRenderedLength: 0,
82
+ sparkStartDistance: 0,
83
+ sparkWidth: 0,
84
+ sparkShape: 'line',
85
+ startsAtMs: 0,
86
+ x: 0,
87
+ y: 0,
88
+ };
89
+ });
90
+ this.availableSlots = [...this.slots].reverse();
91
+ this.sparkBatch = scene.add
92
+ .graphics()
93
+ .setDepth(depth)
94
+ .setActive(false)
95
+ .setVisible(false);
96
+ scene.events.once('shutdown', this.destroy);
97
+ scene.events.once('destroy', this.destroy);
98
+ }
99
+ emit(contact, recipe) {
100
+ if (this.destroyed) {
101
+ return;
102
+ }
103
+ assertImpactContact(contact);
104
+ assertImpactFeedbackRecipe(recipe);
105
+ if (!Number.isFinite(contact.atMs + recipe.durationMs)
106
+ || (recipe.flash !== undefined && !Number.isFinite(contact.atMs + recipe.flash.durationMs))) {
107
+ throw new Error('Impact end time must be finite.');
108
+ }
109
+ const slot = this.availableSlots.pop() ?? this.slots[this.nextSlotIndex];
110
+ this.nextSlotIndex = (slot.index + 1) % this.slots.length;
111
+ if (slot.active) {
112
+ this.recycledEffects += 1;
113
+ }
114
+ else {
115
+ slot.active = true;
116
+ slot.activeIndex = this.activeSlots.length;
117
+ this.activeSlots.push(slot);
118
+ this.peakActiveEffects = Math.max(this.peakActiveEffects, this.activeSlots.length);
119
+ }
120
+ slot.endsAtMs = contact.atMs + recipe.durationMs;
121
+ slot.intensity = Math.max(0.35, Math.min(2.5, contact.intensity));
122
+ slot.startsAtMs = contact.atMs;
123
+ slot.x = contact.x;
124
+ slot.y = contact.y;
125
+ this.emittedEffects += 1;
126
+ this.activateRecipe(slot, contact, recipe);
127
+ if (recipe.flash !== undefined
128
+ && contact.atMs >= this.flashEndsAtMs) {
129
+ const { color, durationMs } = recipe.flash;
130
+ this.flashEndsAtMs = contact.atMs + durationMs;
131
+ this.scene.cameras.main.flash(durationMs, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff, false);
132
+ }
133
+ }
134
+ update(nowMs) {
135
+ if (this.destroyed || !Number.isFinite(nowMs)) {
136
+ return;
137
+ }
138
+ for (let index = this.activeSlots.length - 1; index >= 0; index -= 1) {
139
+ const slot = this.activeSlots[index];
140
+ const durationMs = slot.endsAtMs - slot.startsAtMs;
141
+ if (nowMs >= slot.endsAtMs || durationMs <= 0) {
142
+ this.release(slot);
143
+ continue;
144
+ }
145
+ const progress = Math.max(0, Math.min(1, (nowMs - slot.startsAtMs) / durationMs));
146
+ this.updateRecipe(slot, easeOutCubic(progress), progress);
147
+ }
148
+ this.renderSparkBatch();
149
+ }
150
+ diagnostics() {
151
+ return {
152
+ activeEffects: this.activeSlots.length,
153
+ availableEffects: this.slots.length - this.activeSlots.length,
154
+ capacity: this.slots.length,
155
+ emittedEffects: this.emittedEffects,
156
+ peakActiveEffects: this.peakActiveEffects,
157
+ recycledEffects: this.recycledEffects,
158
+ };
159
+ }
160
+ /** Release every live effect without destroying the prewarmed pool. */
161
+ clear() {
162
+ if (this.destroyed) {
163
+ return;
164
+ }
165
+ while (this.activeSlots.length > 0) {
166
+ const slot = this.activeSlots[this.activeSlots.length - 1];
167
+ this.release(slot);
168
+ }
169
+ this.activeSparkEffects = 0;
170
+ if (this.sparkBatchVisible) {
171
+ this.hideSparkBatch();
172
+ }
173
+ this.flashEndsAtMs = Number.NEGATIVE_INFINITY;
174
+ }
175
+ destroy = () => {
176
+ if (this.destroyed) {
177
+ return;
178
+ }
179
+ this.clear();
180
+ this.destroyed = true;
181
+ this.scene.events.off('shutdown', this.destroy);
182
+ this.scene.events.off('destroy', this.destroy);
183
+ for (const slot of this.slots) {
184
+ slot.ring.destroy();
185
+ slot.rasterRing?.destroy();
186
+ slot.active = false;
187
+ slot.activeIndex = -1;
188
+ }
189
+ this.sparkBatch.destroy();
190
+ this.activeSparkEffects = 0;
191
+ this.activeSlots.length = 0;
192
+ this.availableSlots.length = 0;
193
+ };
194
+ activateRecipe(slot, contact, recipe) {
195
+ const { intensity } = slot;
196
+ const hadSparks = slot.sparkCount > 0;
197
+ const ring = recipe.ring;
198
+ const rasterEntry = this.rasterRings?.recipeFrames[recipe.id];
199
+ slot.rasterFrames = typeof rasterEntry === 'string' ? undefined : rasterEntry;
200
+ slot.rasterFrameIndex = 0;
201
+ const rasterFrame = typeof rasterEntry === 'string' ? rasterEntry : rasterEntry?.[0];
202
+ slot.rasterRingVisible = ring !== undefined && rasterFrame !== undefined;
203
+ slot.rasterRing?.setActive(slot.rasterRingVisible).setVisible(slot.rasterRingVisible);
204
+ if (slot.rasterRingVisible && rasterFrame !== undefined) {
205
+ slot.rasterRing?.setFrame(rasterFrame).setPosition(slot.x, slot.y);
206
+ }
207
+ if (ring === undefined) {
208
+ slot.ringVisible = false;
209
+ slot.ring.setActive(false).setVisible(false);
210
+ }
211
+ else {
212
+ slot.ringStartAlpha = ring.startAlpha ?? 1;
213
+ slot.ringAlphaDelta = (ring.endAlpha ?? 0) - slot.ringStartAlpha;
214
+ slot.ringStartRadius = ring.startRadius * intensity;
215
+ slot.ringRadiusDelta = (ring.endRadius - ring.startRadius) * intensity;
216
+ slot.ringVisible = true;
217
+ slot.ring
218
+ .setPosition(slot.x, slot.y)
219
+ .setRadius(slot.ringStartRadius)
220
+ .setFillStyle(ring.color, 0.12)
221
+ .setStrokeStyle(ring.lineWidth ?? 2, ring.color, 1)
222
+ .setAlpha(slot.ringStartAlpha)
223
+ .setActive(!slot.rasterRingVisible)
224
+ .setVisible(!slot.rasterRingVisible);
225
+ slot.rasterRing?.setDisplaySize(slot.ringStartRadius * 2, slot.ringStartRadius * 2)
226
+ .setAlpha(slot.ringStartAlpha);
227
+ }
228
+ const recipeSparks = recipe.sparks;
229
+ const sparkCount = Math.min(recipeSparks?.count ?? 0, slot.sparkDirectionX.length);
230
+ slot.sparkCount = sparkCount;
231
+ if (hadSparks !== (sparkCount > 0)) {
232
+ this.activeSparkEffects += sparkCount > 0 ? 1 : -1;
233
+ }
234
+ if (recipeSparks !== undefined) {
235
+ slot.sparkShape = recipeSparks.shape ?? 'line';
236
+ slot.sparkAlpha = 1;
237
+ slot.sparkColor = recipeSparks.color;
238
+ slot.sparkDistance = (recipeSparks.startDistance ?? 0) * intensity;
239
+ slot.sparkStartDistance = (recipeSparks.startDistance ?? 0) * intensity;
240
+ slot.sparkDistanceDelta =
241
+ (recipeSparks.endDistance - (recipeSparks.startDistance ?? 0)) * intensity;
242
+ slot.sparkLength = recipeSparks.length * intensity;
243
+ slot.sparkRenderedLength = slot.sparkLength;
244
+ slot.sparkWidth = recipeSparks.width;
245
+ }
246
+ const normalAngle = sparkCount > 0 ? Math.atan2(contact.normalY, contact.normalX) : 0;
247
+ for (let index = 0; recipeSparks !== undefined && index < sparkCount; index += 1) {
248
+ const angleRatio = sparkCount <= 1 ? 0.5 : index / (sparkCount - 1);
249
+ const angle = normalAngle + (angleRatio - 0.5) * recipeSparks.spreadRadians;
250
+ slot.sparkDirectionX[index] = Math.cos(angle);
251
+ slot.sparkDirectionY[index] = Math.sin(angle);
252
+ }
253
+ }
254
+ updateRecipe(slot, progress, frameProgress) {
255
+ if (slot.ringVisible) {
256
+ const radius = slot.ringStartRadius + slot.ringRadiusDelta * progress;
257
+ const alpha = slot.ringStartAlpha + slot.ringAlphaDelta * progress;
258
+ if (slot.rasterRingVisible) {
259
+ if (slot.rasterFrames !== undefined) {
260
+ const count = slot.rasterFrames.length;
261
+ const index = Math.min(count - 1, Math.floor(frameProgress * count));
262
+ const frame = slot.rasterFrames[index];
263
+ if (index !== slot.rasterFrameIndex && frame !== undefined) {
264
+ slot.rasterRing?.setFrame(frame);
265
+ slot.rasterFrameIndex = index;
266
+ }
267
+ }
268
+ slot.rasterRing?.setDisplaySize(radius * 2, radius * 2).setAlpha(alpha);
269
+ }
270
+ else {
271
+ slot.ring.setRadius(radius).setAlpha(alpha);
272
+ }
273
+ }
274
+ const distance = slot.sparkStartDistance + slot.sparkDistanceDelta * progress;
275
+ const linearAlpha = 1 - (slot.sparkShape === 'line' ? progress : frameProgress);
276
+ slot.sparkAlpha = linearAlpha * linearAlpha;
277
+ slot.sparkDistance = distance;
278
+ slot.sparkRenderedLength = slot.sparkShape === 'bracket' || slot.sparkShape === 'cross'
279
+ ? slot.sparkLength
280
+ : slot.sparkLength * (1 - progress * 0.45);
281
+ }
282
+ renderSparkBatch() {
283
+ if (this.activeSparkEffects === 0) {
284
+ if (this.sparkBatchVisible) {
285
+ this.hideSparkBatch();
286
+ }
287
+ return;
288
+ }
289
+ this.sparkBatch.clear();
290
+ for (const slot of this.activeSlots) {
291
+ if (slot.sparkCount === 0 || slot.sparkAlpha <= 0 || slot.sparkRenderedLength <= 0) {
292
+ continue;
293
+ }
294
+ const halfLength = slot.sparkRenderedLength / 2;
295
+ this.sparkBatch.lineStyle(slot.sparkWidth, slot.sparkColor, slot.sparkAlpha);
296
+ this.sparkBatch.beginPath();
297
+ for (let index = 0; index < slot.sparkCount; index += 1) {
298
+ const directionX = slot.sparkDirectionX[index] ?? 0;
299
+ const directionY = slot.sparkDirectionY[index] ?? 0;
300
+ const centerX = slot.x + directionX * slot.sparkDistance;
301
+ const centerY = slot.y + directionY * slot.sparkDistance;
302
+ if (slot.sparkShape !== 'line') {
303
+ // Reuse the same Graphics batch and precomputed directions: no per-hit objects.
304
+ const motion = slot.sparkDistanceDelta < 0 ? -1 : 1;
305
+ const dx = directionX * halfLength;
306
+ const dy = directionY * halfLength;
307
+ const px = -dy;
308
+ const py = dx;
309
+ if (slot.sparkShape === 'chevron') {
310
+ this.sparkBatch.moveTo(centerX - dx * motion + px, centerY - dy * motion + py);
311
+ this.sparkBatch.lineTo(centerX + dx * motion, centerY + dy * motion);
312
+ this.sparkBatch.lineTo(centerX - dx * motion - px, centerY - dy * motion - py);
313
+ }
314
+ else if (slot.sparkShape === 'bracket') {
315
+ this.sparkBatch.moveTo(centerX + dx - px, centerY + dy - py);
316
+ this.sparkBatch.lineTo(centerX + dx + px, centerY + dy + py);
317
+ this.sparkBatch.lineTo(centerX - dx + px, centerY - dy + py);
318
+ }
319
+ else {
320
+ this.sparkBatch.moveTo(centerX - dx, centerY - dy);
321
+ this.sparkBatch.lineTo(centerX + dx, centerY + dy);
322
+ this.sparkBatch.moveTo(centerX - px, centerY - py);
323
+ this.sparkBatch.lineTo(centerX + px, centerY + py);
324
+ }
325
+ continue;
326
+ }
327
+ this.sparkBatch.moveTo(centerX - directionX * halfLength, centerY - directionY * halfLength);
328
+ this.sparkBatch.lineTo(centerX + directionX * halfLength, centerY + directionY * halfLength);
329
+ }
330
+ this.sparkBatch.strokePath();
331
+ }
332
+ if (!this.sparkBatchVisible) {
333
+ this.sparkBatch.setActive(true).setVisible(true);
334
+ this.sparkBatchVisible = true;
335
+ }
336
+ }
337
+ hideSparkBatch() {
338
+ this.sparkBatch.clear().setActive(false).setVisible(false);
339
+ this.sparkBatchVisible = false;
340
+ }
341
+ release(slot) {
342
+ slot.active = false;
343
+ slot.ring.setActive(false).setVisible(false);
344
+ slot.rasterRing?.setActive(false).setVisible(false);
345
+ slot.rasterRingVisible = false;
346
+ slot.rasterFrames = undefined;
347
+ slot.rasterFrameIndex = 0;
348
+ if (slot.sparkCount > 0) {
349
+ this.activeSparkEffects -= 1;
350
+ slot.sparkCount = 0;
351
+ }
352
+ const activeIndex = slot.activeIndex;
353
+ const lastSlot = this.activeSlots.pop();
354
+ if (lastSlot !== undefined && lastSlot !== slot) {
355
+ this.activeSlots[activeIndex] = lastSlot;
356
+ lastSlot.activeIndex = activeIndex;
357
+ }
358
+ slot.activeIndex = -1;
359
+ this.availableSlots.push(slot);
360
+ }
361
+ }
362
+ function easeOutCubic(value) {
363
+ return 1 - (1 - value) ** 3;
364
+ }
365
+ function requireNonNegativeInteger(name, value) {
366
+ if (!Number.isInteger(value) || value < 0 || value > MAX_IMPACT_POOL_SPARKS) {
367
+ throw new Error(`Phaser impact feedback ${name} must be a non-negative integer.`);
368
+ }
369
+ return value;
370
+ }
371
+ function requirePositiveInteger(name, value) {
372
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_IMPACT_POOL_CAPACITY) {
373
+ throw new Error(`Phaser impact feedback ${name} must be a positive integer.`);
374
+ }
375
+ return value;
376
+ }
377
+ function copyRasterRings(input) {
378
+ if (input === undefined) {
379
+ return undefined;
380
+ }
381
+ if (typeof input.textureKey !== 'string' || input.textureKey.length === 0
382
+ || input.textureKey.length > 256) {
383
+ throw new Error('Impact texture key is invalid.');
384
+ }
385
+ const keys = Object.keys(input.recipeFrames);
386
+ if (keys.length > 256) {
387
+ throw new Error('Impact art mappings are limited to 256 recipes.');
388
+ }
389
+ const frames = Object.create(null);
390
+ for (const key of keys) {
391
+ const entry = input.recipeFrames[key];
392
+ if (key.length === 0 || key.length > 256 || entry === undefined
393
+ || (typeof entry !== 'string' && (!Array.isArray(entry) || entry.length === 0
394
+ || entry.length > 64))) {
395
+ throw new Error('Impact art sequence is invalid.');
396
+ }
397
+ const names = typeof entry === 'string' ? [entry] : entry;
398
+ for (const name of names) {
399
+ if (typeof name !== 'string' || name.length === 0 || name.length > 256) {
400
+ throw new Error('Impact art frame name is invalid.');
401
+ }
402
+ }
403
+ frames[key] = typeof entry === 'string' ? entry : Object.freeze([...entry]);
404
+ }
405
+ return Object.freeze({ textureKey: input.textureKey, recipeFrames: Object.freeze(frames) });
406
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "id": "feedback.impact.pool",
3
+ "kind": "presentation",
4
+ "capabilities": [
5
+ "feedback.impact.contact",
6
+ "feedback.impact.recipe",
7
+ "feedback.impact.pool"
8
+ ],
9
+ "entry": "@mpgd/game-runtime/phaser/impact",
10
+ "config": {
11
+ "capacity": {
12
+ "default": 48,
13
+ "min": 1,
14
+ "max": 1024
15
+ },
16
+ "maxSparksPerEffect": {
17
+ "default": 6,
18
+ "min": 0,
19
+ "max": 64
20
+ }
21
+ },
22
+ "tests": [
23
+ "src/impact/index.test.ts",
24
+ "src/phaser/impact.test.ts"
25
+ ],
26
+ "gotchas": [
27
+ "Emit after authoritative collision resolution.",
28
+ "Use the same simulation clock for emit and update.",
29
+ "Full pools recycle slots; clear resets retained state.",
30
+ "Raster rings require registered texture frames.",
31
+ "Graphics rebuilds its command buffer while sparks are active.",
32
+ "Scene shutdown destroys pool-owned objects."
33
+ ],
34
+ "acceptance": "pnpm --dir packages/game-runtime test"
35
+ }
@@ -0,0 +1,35 @@
1
+ # Pooled impact feedback
2
+
3
+ Capability: `feedback.impact.pool`. Kind: presentation.
4
+
5
+ Use `@mpgd/game-runtime/impact` for validated contacts, authored recipe catalogs
6
+ and ordered rule matching without importing Phaser. Use
7
+ `@mpgd/game-runtime/phaser/impact` for `PhaserImpactFeedbackPool`.
8
+ The renderer takes only contact scalars; damage, rewards and collision authority
9
+ remain in the simulation.
10
+
11
+ ```ts
12
+ const feedback = new PhaserImpactFeedbackPool(scene, { capacity: 48 });
13
+ feedback.emit(contact, recipe);
14
+ feedback.update(scene.time.now);
15
+ // Shutdown/destroy automatically disposes the pool; destroy() is idempotent.
16
+ ```
17
+
18
+ Config: capacity 1–1024 (default 48), maxSparksPerEffect 0–64 (default 6),
19
+ optional finite depth and rasterRings with an already registered texture and
20
+ recipe-to-frame mappings. Catalogs allow 256 recipes and 1024 rules. Higher
21
+ priority wins; authored order breaks ties. Inputs are copied at catalog/pool
22
+ construction. Construct once per scene; do not recreate for every contact.
23
+
24
+ Gotchas: emit and update must use the same nonnegative simulation clock.
25
+ Emit after authoritative collision resolution. Full pools recycle the next
26
+ slot and completely reset visible state. Sparks share one Graphics batch;
27
+ no GameObjects or tweens are created per hit, but Graphics rebuilds its command
28
+ buffer each active frame. The caller owns recipes, texture loading and memory
29
+ budgets; this helper does not certify device FPS. Frame strips contain 1–64
30
+ registered names and run on the effect clock. Scene teardown destroys owned
31
+ objects and detaches event listeners. Input devices have no role in this API.
32
+
33
+ Tests: `src/impact/index.test.ts`, `src/phaser/impact.test.ts`, headless and
34
+ packed consumer fixtures. Acceptance: `pnpm --dir packages/game-runtime test`;
35
+ visually playtest rings/spark variants with the application's authored recipes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -50,6 +50,18 @@
50
50
  "./game": {
51
51
  "types": "./dist/game/index.d.ts",
52
52
  "default": "./dist/game/index.js"
53
+ },
54
+ "./dom": {
55
+ "types": "./dist/dom/index.d.ts",
56
+ "default": "./dist/dom/index.js"
57
+ },
58
+ "./impact": {
59
+ "types": "./dist/impact/index.d.ts",
60
+ "default": "./dist/impact/index.js"
61
+ },
62
+ "./phaser/impact": {
63
+ "types": "./dist/phaser/impact.d.ts",
64
+ "default": "./dist/phaser/impact.js"
53
65
  }
54
66
  },
55
67
  "files": [
@@ -64,8 +76,8 @@
64
76
  "@types/node": "^24.0.0"
65
77
  },
66
78
  "dependencies": {
67
- "@mpgd/game-services": "0.18.1",
68
- "@mpgd/platform": "0.16.0"
79
+ "@mpgd/game-services": "0.19.0",
80
+ "@mpgd/platform": "0.17.0"
69
81
  },
70
82
  "repository": {
71
83
  "type": "git",
@@ -90,7 +102,7 @@
90
102
  "scripts": {
91
103
  "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-runtime",
92
104
  "check": "ttsc --noEmit && ttsc --noEmit -p tsconfig.headless.json",
93
- "test": "pnpm build && vitest run && node test/dist-import.mjs && node test/phaser-dist-import.mjs && ttsc --noEmit -p test/tsconfig.json && ttsc --noEmit -p test/tsconfig.phaser.json && node test/package-import.mjs",
105
+ "test": "pnpm build && vitest run && node test/dist-import.mjs && node test/phaser-dist-import.mjs && ttsc --noEmit -p test/tsconfig.json && ttsc --noEmit -p test/tsconfig.phaser.json && ttsc --noEmit -p test/tsconfig.dom.json && node test/package-import.mjs",
94
106
  "lint": "pnpm check",
95
107
  "format": "ttsc format",
96
108
  "fix": "ttsc fix"