@caperjs/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/build/assetpack.mjs +90 -0
  2. package/build/plugins/pruneFallbacks.mjs +25 -2
  3. package/lib/{CaptionsPlugin-BLbLl3WB.js → CaptionsPlugin-cSJj-R9M.js} +4 -4
  4. package/lib/{CaptionsPlugin-BLbLl3WB.js.map → CaptionsPlugin-cSJj-R9M.js.map} +1 -1
  5. package/lib/{DataAdapter-aHSNM_26.js → DataAdapter-D2tc6hx9.js} +5 -5
  6. package/lib/{DataAdapter-aHSNM_26.js.map → DataAdapter-D2tc6hx9.js.map} +1 -1
  7. package/lib/{DebugRenderer-8LjHtBzA.js → DebugRenderer-CGUKy6Fy.js} +2 -2
  8. package/lib/{DebugRenderer-8LjHtBzA.js.map → DebugRenderer-CGUKy6Fy.js.map} +1 -1
  9. package/lib/{DevToolsPlugin-Vrk6aOtS.js → DevToolsPlugin-B8GX3H9b.js} +2 -2
  10. package/lib/{DevToolsPlugin-Vrk6aOtS.js.map → DevToolsPlugin-B8GX3H9b.js.map} +1 -1
  11. package/lib/{GSAPPlugin-Hm3F4zdg.js → GSAPPlugin-Br9AjuTD.js} +5 -5
  12. package/lib/{GSAPPlugin-Hm3F4zdg.js.map → GSAPPlugin-Br9AjuTD.js.map} +1 -1
  13. package/lib/{LayoutPlugin-Dpgeawfw.js → LayoutPlugin-BX3OP7pw.js} +4 -4
  14. package/lib/{LayoutPlugin-Dpgeawfw.js.map → LayoutPlugin-BX3OP7pw.js.map} +1 -1
  15. package/lib/{SpinePlugin-B08yAw1S.js → SpinePlugin-C-JDdXpf.js} +2 -2
  16. package/lib/{SpinePlugin-B08yAw1S.js.map → SpinePlugin-C-JDdXpf.js.map} +1 -1
  17. package/lib/{StatsPlugin-D2Xa5j97.js → StatsPlugin-yJlJtoQ2.js} +7 -10
  18. package/lib/{StatsPlugin-D2Xa5j97.js.map → StatsPlugin-yJlJtoQ2.js.map} +1 -1
  19. package/lib/{VoiceOverPlugin-qtFlqpRn.js → VoiceOverPlugin-DjSZp4Ko.js} +4 -4
  20. package/lib/{VoiceOverPlugin-qtFlqpRn.js.map → VoiceOverPlugin-DjSZp4Ko.js.map} +1 -1
  21. package/lib/caper.mjs +156 -1927
  22. package/lib/caper.mjs.map +1 -1
  23. package/lib/const-C3sxeYhl.js +1746 -0
  24. package/lib/const-C3sxeYhl.js.map +1 -0
  25. package/lib/core/create.d.ts.map +1 -1
  26. package/lib/padding-1kHDpGlQ.js +41 -0
  27. package/lib/padding-1kHDpGlQ.js.map +1 -0
  28. package/lib/plugins/FullScreenPlugin.d.ts +0 -5
  29. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  30. package/lib/plugins/KeyboardPlugin.d.ts +0 -1
  31. package/lib/plugins/KeyboardPlugin.d.ts.map +1 -1
  32. package/lib/plugins/Plugin.d.ts +101 -2
  33. package/lib/plugins/Plugin.d.ts.map +1 -1
  34. package/lib/plugins/SceneManagerPlugin.d.ts +0 -1
  35. package/lib/plugins/SceneManagerPlugin.d.ts.map +1 -1
  36. package/lib/plugins/StatsPlugin.d.ts +0 -1
  37. package/lib/plugins/StatsPlugin.d.ts.map +1 -1
  38. package/lib/plugins/TimerPlugin.d.ts.map +1 -1
  39. package/lib/plugins/WebEventsPlugin.d.ts +0 -1
  40. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  41. package/lib/plugins/breakpoints/BreakpointPlugin.d.ts.map +1 -1
  42. package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -1
  43. package/lib/plugins/input/AbstractControls.d.ts +46 -7
  44. package/lib/plugins/input/AbstractControls.d.ts.map +1 -1
  45. package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
  46. package/lib/plugins/input/controlsCore.d.ts +58 -0
  47. package/lib/plugins/input/controlsCore.d.ts.map +1 -0
  48. package/lib/plugins/input/controlsCore.test.d.ts +2 -0
  49. package/lib/plugins/input/controlsCore.test.d.ts.map +1 -0
  50. package/lib/plugins/input/keyboard/KeyboardControls.d.ts +15 -0
  51. package/lib/plugins/input/keyboard/KeyboardControls.d.ts.map +1 -0
  52. package/lib/plugins/input/keyboard/index.d.ts +1 -1
  53. package/lib/plugins/input/keyboard/index.d.ts.map +1 -1
  54. package/lib/plugins/input/touch/VirtualControls.d.ts +7 -19
  55. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  56. package/lib/{registries-m2vKTIih.js → registries-BfV-VF7a.js} +565 -552
  57. package/lib/registries-BfV-VF7a.js.map +1 -0
  58. package/package.json +1 -1
  59. package/src/core/create.ts +11 -0
  60. package/src/plugins/FullScreenPlugin.ts +5 -18
  61. package/src/plugins/KeyboardPlugin.ts +3 -10
  62. package/src/plugins/Plugin.test.ts +130 -2
  63. package/src/plugins/Plugin.ts +159 -3
  64. package/src/plugins/SceneManagerPlugin.ts +3 -8
  65. package/src/plugins/StatsPlugin.ts +3 -10
  66. package/src/plugins/TimerPlugin.ts +1 -2
  67. package/src/plugins/WebEventsPlugin.ts +9 -18
  68. package/src/plugins/breakpoints/BreakpointPlugin.ts +1 -2
  69. package/src/plugins/captions/CaptionsPlugin.ts +1 -1
  70. package/src/plugins/gesture/GesturePlugin.ts +4 -8
  71. package/src/plugins/input/AbstractControls.ts +103 -8
  72. package/src/plugins/input/Controls.test.ts +56 -0
  73. package/src/plugins/input/InputPlugin.ts +6 -14
  74. package/src/plugins/input/controlsCore.test.ts +151 -0
  75. package/src/plugins/input/controlsCore.ts +162 -0
  76. package/src/plugins/input/keyboard/KeyboardControls.ts +84 -0
  77. package/src/plugins/input/keyboard/index.ts +1 -1
  78. package/src/plugins/input/touch/VirtualControls.ts +41 -142
  79. package/src/version.ts +1 -1
  80. package/lib/plugins/input/keyboard/KeyboardContols.d.ts +0 -33
  81. package/lib/plugins/input/keyboard/KeyboardContols.d.ts.map +0 -1
  82. package/lib/registries-m2vKTIih.js.map +0 -1
  83. package/src/plugins/input/keyboard/KeyboardContols.ts +0 -206
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caperjs/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "title": "Caper",
5
5
  "description": "An opinionated HTML game framework built on PixiJS v8",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import { type RegisterSWOptions } from 'vite-plugin-pwa/types';
2
2
  import { sayHello } from '../hello';
3
+ import { setDefaultFactoryMethods } from '../mixins/factory/defaults';
3
4
  import type { PluginListItem } from '../plugins';
4
5
  import type { AppTypeOverrides, SceneImportListItem } from '../utils';
5
6
  import { triggerViteError } from '../utils/vite';
@@ -147,6 +148,16 @@ export async function create(
147
148
  domElement: string | Window | HTMLElement = DEFAULT_GAME_CONTAINER_ID,
148
149
  speak: boolean = true,
149
150
  ): Promise<App> {
151
+ // The package declares `sideEffects: false`, which lets an app's bundler drop
152
+ // const.ts's top-level registration from production builds. Re-registering here
153
+ // anchors the table to create(), which every app calls, so it can't be shaken.
154
+ // The import MUST stay dynamic: a static `core → const.ts` edge closes the
155
+ // factory/ui module cycle (see mixins/factory/defaults.ts and the
156
+ // importOrder.*.test.ts guards). By the time create() runs, nothing is
157
+ // mid-evaluation, so loading const.ts here is safe. Idempotent in dev/test,
158
+ // where the module side effect already registered the table.
159
+ const { defaultFactoryMethods } = await import('../mixins/factory/const');
160
+ setDefaultFactoryMethods(defaultFactoryMethods);
150
161
  await documentReady();
151
162
  checkWebGL();
152
163
  if (speak) {
@@ -143,24 +143,11 @@ export class FullScreenPlugin extends Plugin implements IFullScreenPlugin {
143
143
  * ```
144
144
  */
145
145
  public initialize(): void {
146
- document.addEventListener('fullscreenchange', this._onFullScreenChange);
147
- document.addEventListener('webkitfullscreenchange', this._onFullScreenChange);
148
- document.addEventListener('mozfullscreenchange', this._onFullScreenChange);
149
- document.addEventListener('msfullscreenchange', this._onFullScreenChange);
150
- document.addEventListener('fullscreenchange', this._onFullScreenChange);
151
- }
152
-
153
- /**
154
- * Cleans up the plugin by removing all event listeners.
155
- * Called automatically when the plugin is destroyed.
156
- */
157
- public destroy() {
158
- document.removeEventListener('fullscreenchange', this._onFullScreenChange);
159
- document.removeEventListener('webkitfullscreenchange', this._onFullScreenChange);
160
- document.removeEventListener('mozfullscreenchange', this._onFullScreenChange);
161
- document.removeEventListener('msfullscreenchange', this._onFullScreenChange);
162
- document.removeEventListener('fullscreenchange', this._onFullScreenChange);
163
- super.destroy();
146
+ this.listen(document, 'fullscreenchange', this._onFullScreenChange);
147
+ this.listen(document, 'webkitfullscreenchange', this._onFullScreenChange);
148
+ this.listen(document, 'mozfullscreenchange', this._onFullScreenChange);
149
+ this.listen(document, 'msfullscreenchange', this._onFullScreenChange);
150
+ this.listen(document, 'fullscreenchange', this._onFullScreenChange);
164
151
  }
165
152
 
166
153
  /**
@@ -61,15 +61,8 @@ export class KeyboardPlugin extends Plugin implements IKeyboardPlugin {
61
61
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
62
62
  public initialize(_options: any, _app: IApplication): void {
63
63
  // track which keys are down
64
- document.addEventListener('keydown', this._handleKeyDown);
65
- document.addEventListener('keyup', this._handleKeyUp);
66
- }
67
-
68
- public destroy() {
69
- document.removeEventListener('keydown', this._handleKeyDown);
70
- document.removeEventListener('keyup', this._handleKeyUp);
71
- document.removeEventListener('keydown', this._handleEvent);
72
- document.removeEventListener('keyup', this._handleEvent);
64
+ this.listen(document, 'keydown', this._handleKeyDown as EventListener);
65
+ this.listen(document, 'keyup', this._handleKeyUp as EventListener);
73
66
  }
74
67
 
75
68
  public onKeyDown(key?: KeyboardKey): KeySignal {
@@ -145,7 +138,7 @@ export class KeyboardPlugin extends Plugin implements IKeyboardPlugin {
145
138
  }
146
139
 
147
140
  private _listen(eventType: KeyboardEventType): void {
148
- document.addEventListener(eventType, this._handleEvent);
141
+ this.listen(document, eventType, this._handleEvent as EventListener);
149
142
  }
150
143
 
151
144
  private _handleEvent(event: KeyboardEvent): void {
@@ -1,15 +1,21 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  // Plugin.ts transitively imports Application → Pixi display graph. Stub it.
4
4
  vi.mock('../core', () => ({
5
5
  coreFunctionRegistry: {},
6
6
  coreSignalRegistry: {},
7
7
  }));
8
+
9
+ const { mockTicker } = vi.hoisted(() => ({
10
+ mockTicker: { add: vi.fn(), remove: vi.fn() },
11
+ }));
12
+
8
13
  vi.mock('../core/Application', () => ({
9
- Application: { getInstance: () => ({}) },
14
+ Application: { getInstance: () => ({ ticker: mockTicker }) },
10
15
  }));
11
16
 
12
17
  import { Signal } from '../signals';
18
+ import { Logger } from '../utils';
13
19
  import { Plugin } from './Plugin';
14
20
 
15
21
  class TestPlugin extends Plugin<{ foo: string }> {
@@ -71,3 +77,125 @@ describe('Plugin', () => {
71
77
  await expect(p.postInitialize({} as never)).resolves.toBeUndefined();
72
78
  });
73
79
  });
80
+
81
+ describe('Plugin lifecycle cleanup', () => {
82
+ beforeEach(() => {
83
+ vi.restoreAllMocks();
84
+ mockTicker.add.mockClear();
85
+ mockTicker.remove.mockClear();
86
+ });
87
+
88
+ it('runs disposers LIFO, and only once', () => {
89
+ const p = new TestPlugin();
90
+ const order: string[] = [];
91
+ p.addDisposer(
92
+ () => order.push('a'),
93
+ () => order.push('b'),
94
+ );
95
+ p.addDisposer(() => order.push('c'));
96
+
97
+ p.destroy();
98
+ expect(order).toEqual(['c', 'b', 'a']);
99
+
100
+ p.destroy();
101
+ expect(order).toEqual(['c', 'b', 'a']);
102
+ });
103
+
104
+ it('destroy is idempotent for signal connections too', () => {
105
+ const p = new TestPlugin();
106
+ const s = new Signal<() => void>();
107
+ const handler = vi.fn();
108
+ p.addSignalConnection(s.connect(handler));
109
+
110
+ p.destroy();
111
+ expect(() => p.destroy()).not.toThrow();
112
+ s.emit();
113
+ expect(handler).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('a throwing disposer does not stop the rest, and is reported', () => {
117
+ const errorSpy = vi.spyOn(Logger, 'error').mockImplementation(() => undefined);
118
+ const p = new TestPlugin();
119
+ const first = vi.fn();
120
+ const last = vi.fn();
121
+
122
+ p.addDisposer(first);
123
+ p.addDisposer(() => {
124
+ throw new Error('boom');
125
+ });
126
+ p.addDisposer(last);
127
+
128
+ expect(() => p.destroy()).not.toThrow();
129
+ expect(first).toHaveBeenCalledTimes(1);
130
+ expect(last).toHaveBeenCalledTimes(1);
131
+ expect(errorSpy).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it('listen() attaches now and detaches on destroy', () => {
135
+ const p = new TestPlugin();
136
+ const target = document.createElement('div');
137
+ const handler = vi.fn();
138
+
139
+ p.listen(target, 'click', handler);
140
+ target.dispatchEvent(new Event('click'));
141
+ expect(handler).toHaveBeenCalledTimes(1);
142
+
143
+ p.destroy();
144
+ target.dispatchEvent(new Event('click'));
145
+ expect(handler).toHaveBeenCalledTimes(1);
146
+ });
147
+
148
+ it('listen() detaches capture-phase listeners with matching options', () => {
149
+ const p = new TestPlugin();
150
+ const parent = document.createElement('div');
151
+ const child = document.createElement('div');
152
+ parent.appendChild(child);
153
+ document.body.appendChild(parent);
154
+ const handler = vi.fn();
155
+
156
+ p.listen(parent, 'click', handler, true);
157
+ child.dispatchEvent(new Event('click', { bubbles: true }));
158
+ expect(handler).toHaveBeenCalledTimes(1);
159
+
160
+ p.destroy();
161
+ child.dispatchEvent(new Event('click', { bubbles: true }));
162
+ expect(handler).toHaveBeenCalledTimes(1);
163
+
164
+ parent.remove();
165
+ });
166
+
167
+ it('listen() returns an early-removal fn that is safe to call twice', () => {
168
+ const p = new TestPlugin();
169
+ const target = document.createElement('div');
170
+ const handler = vi.fn();
171
+
172
+ const remove = p.listen(target, 'click', handler);
173
+ remove();
174
+ remove();
175
+ target.dispatchEvent(new Event('click'));
176
+ expect(handler).not.toHaveBeenCalled();
177
+ expect(() => p.destroy()).not.toThrow();
178
+ });
179
+
180
+ it('addTickerCallback() adds to the app ticker and removes on destroy', () => {
181
+ const p = new TestPlugin();
182
+ const fn = vi.fn();
183
+
184
+ p.addTickerCallback(fn, p, 5);
185
+ expect(mockTicker.add).toHaveBeenCalledWith(fn, p, 5);
186
+
187
+ p.destroy();
188
+ expect(mockTicker.remove).toHaveBeenCalledWith(fn, p);
189
+ });
190
+
191
+ it('addTickerCallback() returns an early-removal fn that only removes once', () => {
192
+ const p = new TestPlugin();
193
+ const fn = vi.fn();
194
+
195
+ const remove = p.addTickerCallback(fn);
196
+ remove();
197
+ remove();
198
+ p.destroy();
199
+ expect(mockTicker.remove).toHaveBeenCalledTimes(1);
200
+ });
201
+ });
@@ -1,8 +1,9 @@
1
+ import type { Ticker } from 'pixi.js';
1
2
  import type { IApplication, ICoreFunctions, ICoreSignals } from '../core';
2
3
  import { coreFunctionRegistry, coreSignalRegistry } from '../core';
3
4
  import { Application } from '../core/Application';
4
5
  import { SignalConnection, SignalConnections } from '../signals';
5
- import { type AppTypeOverrides, bindAllMethods, ImportListItemModule } from '../utils';
6
+ import { type AppTypeOverrides, bindAllMethods, ImportListItemModule, Logger } from '../utils';
6
7
 
7
8
  /**
8
9
  * Public contract for a Caper plugin. A plugin is a long-lived object
@@ -25,7 +26,40 @@ import { type AppTypeOverrides, bindAllMethods, ImportListItemModule } from '../
25
26
  * methods are called from scenes / other plugins / the app.
26
27
  * 5. `destroy()` — called when the app shuts down. Tear down
27
28
  * connections, disconnect signals, free resources. The base class
28
- * already disconnects everything added via `addSignalConnection`.
29
+ * already disconnects everything added via `addSignalConnection`
30
+ * and runs everything registered via the cleanup primitives below.
31
+ *
32
+ * **Cleanup primitives.** Don't hand-roll matching add/remove pairs —
33
+ * register the resource through the base class and `destroy()` releases
34
+ * it for you. Overriding `destroy()` is still fine; just call
35
+ * `super.destroy()`.
36
+ *
37
+ * - `addSignalConnection(...)` — signal connections.
38
+ * - `listen(target, type, handler, options?)` — DOM listeners. Adds
39
+ * now, removes on destroy with the same capture/options semantics.
40
+ * - `addTickerCallback(fn, context?, priority?)` — ticker callbacks on
41
+ * `app.ticker`. Added now, removed on destroy.
42
+ * - `addDisposer(...fns)` — anything else (DOM nodes, timers, third
43
+ * party handles). Disposers run last-in-first-out on destroy, each
44
+ * isolated so one failure can't skip the others.
45
+ *
46
+ * `listen` and `addTickerCallback` also return a removal function, for
47
+ * the rarer case where you need to detach before the plugin dies. It's
48
+ * safe to call more than once.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * public initialize(): void {
53
+ * this.listen(window, 'resize', this._onResize);
54
+ * this.addTickerCallback(this._update);
55
+ * this.addDisposer(() => this._el.remove());
56
+ * }
57
+ * // no destroy() override needed — the base class cleans all three up
58
+ * ```
59
+ *
60
+ * Listeners that get attached and detached repeatedly at runtime (an
61
+ * activate/deactivate cycle, say) should stay manual — the primitives
62
+ * are for resources whose lifetime matches the plugin's.
29
63
  *
30
64
  * Discovery: plugin classes under `src/plugins/` are auto-discovered
31
65
  * by the Vite plugin if they default-export a class. Annotate with
@@ -79,7 +113,11 @@ export interface IPlugin<O = any> {
79
113
  */
80
114
  postInitialize(_app: IApplication): Promise<void> | void;
81
115
 
82
- /** Tear down. Called on app shutdown. */
116
+ /**
117
+ * Tear down. Called on app shutdown. Runs every registered disposer
118
+ * (last-in-first-out) and disconnects every tracked signal connection.
119
+ * Safe to call more than once — the second call is a no-op.
120
+ */
83
121
  destroy(): void;
84
122
 
85
123
  /**
@@ -91,6 +129,40 @@ export interface IPlugin<O = any> {
91
129
  /** Disconnect every connection added via `addSignalConnection`. */
92
130
  clearSignalConnections(): void;
93
131
 
132
+ /**
133
+ * Register cleanup callbacks to run on `destroy`. Use for resources
134
+ * the other primitives don't cover — DOM nodes, timers, third-party
135
+ * handles.
136
+ *
137
+ * Disposers run last-in-first-out (so teardown mirrors setup), and each
138
+ * one is isolated: a throwing disposer is reported and the rest still
139
+ * run.
140
+ */
141
+ addDisposer(...fns: Array<() => void>): void;
142
+
143
+ /**
144
+ * Add a DOM event listener now and remove it on `destroy`, with the
145
+ * same capture/options semantics it was added with.
146
+ *
147
+ * @returns A removal function, for the rarer case where the listener
148
+ * must come off before the plugin dies. Safe to call more than once.
149
+ */
150
+ listen(
151
+ target: EventTarget,
152
+ type: string,
153
+ handler: EventListenerOrEventListenerObject,
154
+ options?: AddEventListenerOptions | boolean,
155
+ ): () => void;
156
+
157
+ /**
158
+ * Add a callback to `app.ticker` now and remove it on `destroy`. The
159
+ * ticker is resolved when this is called, not at construction.
160
+ *
161
+ * @returns A removal function, for the rarer case where the callback
162
+ * must come off before the plugin dies. Safe to call more than once.
163
+ */
164
+ addTickerCallback(fn: (ticker: Ticker) => void, context?: unknown, priority?: number): () => void;
165
+
94
166
  /** Register methods this plugin exposes via the core function registry. */
95
167
  registerCoreFunctions(): void;
96
168
 
@@ -117,6 +189,8 @@ export class Plugin<O = any> implements IPlugin<O> {
117
189
  private static readonly __caper_method_binding_root = true;
118
190
  // A collection of signal connections.
119
191
  protected _signalConnections: SignalConnections = new SignalConnections();
192
+ // Cleanup callbacks registered via addDisposer / listen / addTickerCallback.
193
+ protected _disposers: Array<() => void> = [];
120
194
 
121
195
  protected _options: O;
122
196
 
@@ -132,7 +206,25 @@ export class Plugin<O = any> implements IPlugin<O> {
132
206
  return Application.getInstance();
133
207
  }
134
208
 
209
+ /**
210
+ * Tear down the plugin. Runs every registered disposer (last-in-first-out,
211
+ * each isolated so one failure can't skip the others), then disconnects
212
+ * every tracked signal connection. Idempotent.
213
+ *
214
+ * Subclasses may override this, but must call `super.destroy()`.
215
+ */
135
216
  public destroy(): void {
217
+ // swap the list out first, so a disposer that (indirectly) re-enters
218
+ // destroy can't run anything twice
219
+ const disposers = this._disposers;
220
+ this._disposers = [];
221
+ for (let i = disposers.length - 1; i >= 0; i--) {
222
+ try {
223
+ disposers[i]();
224
+ } catch (e) {
225
+ Logger.error(`Plugin "${this.id}" threw while running a disposer:`, e);
226
+ }
227
+ }
136
228
  this._signalConnections.disconnectAll();
137
229
  }
138
230
 
@@ -164,6 +256,70 @@ export class Plugin<O = any> implements IPlugin<O> {
164
256
  this._signalConnections.disconnectAll();
165
257
  }
166
258
 
259
+ /**
260
+ * Register cleanup callbacks to run on `destroy`, last-in-first-out.
261
+ * Use for resources the other primitives don't cover — DOM nodes,
262
+ * timers, third-party handles.
263
+ * @param fns - The cleanup callbacks to register.
264
+ */
265
+ public addDisposer(...fns: Array<() => void>): void {
266
+ for (const fn of fns) {
267
+ this._disposers.push(fn);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Add a DOM event listener now and remove it on `destroy`, with the same
273
+ * capture/options semantics it was added with.
274
+ * @param target - The event target to listen on.
275
+ * @param type - The event type.
276
+ * @param handler - The listener.
277
+ * @param options - Passed through to both add and remove, so capture-phase
278
+ * listeners detach correctly.
279
+ * @returns A removal function for early detachment. Safe to call more than once.
280
+ */
281
+ public listen(
282
+ target: EventTarget,
283
+ type: string,
284
+ handler: EventListenerOrEventListenerObject,
285
+ options?: AddEventListenerOptions | boolean,
286
+ ): () => void {
287
+ target.addEventListener(type, handler, options);
288
+ let removed = false;
289
+ const remove = () => {
290
+ if (removed) {
291
+ return;
292
+ }
293
+ removed = true;
294
+ target.removeEventListener(type, handler, options);
295
+ };
296
+ this.addDisposer(remove);
297
+ return remove;
298
+ }
299
+
300
+ /**
301
+ * Add a callback to `app.ticker` now and remove it on `destroy`. The ticker
302
+ * is resolved when this is called, not at construction.
303
+ * @param fn - The ticker callback.
304
+ * @param context - The context to invoke the callback with.
305
+ * @param priority - Pixi `UPDATE_PRIORITY` for the callback.
306
+ * @returns A removal function for early detachment. Safe to call more than once.
307
+ */
308
+ public addTickerCallback(fn: (ticker: Ticker) => void, context?: unknown, priority?: number): () => void {
309
+ const ticker = this.app.ticker;
310
+ ticker.add(fn, context, priority);
311
+ let removed = false;
312
+ const remove = () => {
313
+ if (removed) {
314
+ return;
315
+ }
316
+ removed = true;
317
+ ticker.remove(fn, context);
318
+ };
319
+ this.addDisposer(remove);
320
+ return remove;
321
+ }
322
+
167
323
  /**
168
324
  * @override
169
325
  * @protected
@@ -139,12 +139,6 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
139
139
  this._defaultLoadMethod = method;
140
140
  }
141
141
 
142
- public destroy(): void {
143
- window.removeEventListener('hashchange', this._onHashChange);
144
- this._debugMenu?.parentElement?.removeChild(this._debugMenu);
145
- super.destroy();
146
- }
147
-
148
142
  public async initialize(_options: any, app: IApplication): Promise<void> {
149
143
  this._debugVisible =
150
144
  this.app.config?.showSceneDebugMenu === true || (isDev && this.app.config?.showSceneDebugMenu !== false);
@@ -374,7 +368,7 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
374
368
  }
375
369
 
376
370
  private _listenForHashChange() {
377
- window.addEventListener('hashchange', this._onHashChange);
371
+ this.listen(window, 'hashchange', this._onHashChange);
378
372
  }
379
373
 
380
374
  private _onHashChange() {
@@ -577,6 +571,7 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
577
571
  this._debugMenu.appendChild(icon);
578
572
 
579
573
  (Application.containerElement || document.body).appendChild(this._debugMenu);
574
+ this.addDisposer(() => this._debugMenu?.parentElement?.removeChild(this._debugMenu));
580
575
 
581
576
  this._sceneSelect = document.createElement('select');
582
577
  this._sceneSelect.style.cssText =
@@ -651,7 +646,7 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
651
646
 
652
647
  this._debugMenu.appendChild(this._sceneSelect);
653
648
 
654
- this._debugMenu.addEventListener('change', (e: Event) => {
649
+ this.listen(this._debugMenu, 'change', (e: Event) => {
655
650
  if (this._queue) {
656
651
  e.preventDefault();
657
652
  return;
@@ -1,4 +1,4 @@
1
- import { Ticker, UPDATE_PRIORITY } from 'pixi.js';
1
+ import { UPDATE_PRIORITY } from 'pixi.js';
2
2
  import Stats from 'stats.js';
3
3
  import { Application } from '../core/Application';
4
4
  import { Plugin } from './Plugin';
@@ -12,6 +12,7 @@ export class StatsPlugin extends Plugin {
12
12
  this.stats.dom.id = 'stats';
13
13
 
14
14
  Application.containerElement?.appendChild(this.stats.dom);
15
+ this.addDisposer(() => this.stats.dom.remove());
15
16
 
16
17
  this.stats.dom.style.position = 'absolute';
17
18
  this.stats.dom.style.top = 'auto';
@@ -19,14 +20,6 @@ export class StatsPlugin extends Plugin {
19
20
  this.stats.dom.style.right = '0';
20
21
  this.stats.dom.style.left = 'auto';
21
22
 
22
- Ticker.shared.add(this.stats.update, this.stats, UPDATE_PRIORITY.UTILITY);
23
- }
24
-
25
- public destroy(): void {
26
- if (this.stats) {
27
- Ticker.shared.remove(this.stats.update, this.stats);
28
- this.stats.dom.remove();
29
- }
30
- super.destroy();
23
+ this.addTickerCallback(this.stats.update, this.stats, UPDATE_PRIORITY.UTILITY);
31
24
  }
32
25
  }
@@ -676,7 +676,7 @@ export class TimerPlugin extends Plugin implements ITimerPlugin {
676
676
 
677
677
  public postInitialize() {
678
678
  // Set up update loop for main thread timers only
679
- this.app.ticker.add(this.update);
679
+ this.addTickerCallback(this.update);
680
680
 
681
681
  // Initialize worker if needed
682
682
  this.initWorker();
@@ -763,7 +763,6 @@ export class TimerPlugin extends Plugin implements ITimerPlugin {
763
763
  }
764
764
 
765
765
  public destroy(): void {
766
- this.app.ticker.remove(this.update);
767
766
  this.worker?.terminate();
768
767
  this.worker = null;
769
768
  this.timerCallbacks.clear();
@@ -44,26 +44,17 @@ export class WebEventsPlugin extends Plugin implements IWebEventsPlugin {
44
44
  }
45
45
 
46
46
  public initialize(): void {
47
- document.addEventListener('visibilitychange', this._onVisibilityChanged, false);
48
- window.addEventListener('pagehide', this._onPageHide, false);
49
- window.addEventListener('pageshow', this._onPageShow, false);
50
- window.addEventListener('resize', this._onResize);
51
- document.addEventListener('fullscreenchange', this._onResize);
52
- window.addEventListener('orientationchange', this._onOrientationChanged);
47
+ this.listen(document, 'visibilitychange', this._onVisibilityChanged, false);
48
+ this.listen(window, 'pagehide', this._onPageHide, false);
49
+ this.listen(window, 'pageshow', this._onPageShow, false);
50
+ this.listen(window, 'resize', this._onResize);
51
+ this.listen(document, 'fullscreenchange', this._onResize);
52
+ this.listen(window, 'orientationchange', this._onOrientationChanged);
53
53
  // installed PWAs (standalone/fullscreen display modes) can miss or mistime window.resize
54
54
  // during launch transitions; visualViewport reports the settled size
55
- window.visualViewport?.addEventListener('resize', this._onVisualViewportResize);
56
- }
57
-
58
- public destroy() {
59
- document.removeEventListener('visibilitychange', this._onVisibilityChanged, false);
60
- window.removeEventListener('resize', this._onResize);
61
- document.removeEventListener('fullscreenchange', this._onResize);
62
- window.removeEventListener('pagehide', this._onPageHide, false);
63
- window.removeEventListener('pageshow', this._onPageShow, false);
64
- window.removeEventListener('orientationchange', this._onOrientationChanged);
65
- window.visualViewport?.removeEventListener('resize', this._onVisualViewportResize);
66
- super.destroy();
55
+ if (window.visualViewport) {
56
+ this.listen(window.visualViewport, 'resize', this._onVisualViewportResize);
57
+ }
67
58
  }
68
59
 
69
60
  protected getCoreSignals(): string[] {
@@ -150,7 +150,6 @@ export class BreakpointPlugin extends Plugin<BreakpointPluginOptions> implements
150
150
  }
151
151
 
152
152
  public destroy(): void {
153
- this._pointerQuery?.removeEventListener('change', this._onPointerChange);
154
153
  this._pointerQuery = null;
155
154
  for (const signal of this._enter.values()) signal.disconnectAll();
156
155
  for (const signal of this._leave.values()) signal.disconnectAll();
@@ -259,7 +258,7 @@ export class BreakpointPlugin extends Plugin<BreakpointPluginOptions> implements
259
258
  }
260
259
  this._pointerQuery = window.matchMedia('(pointer: coarse)');
261
260
  this._pointer = this._pointerQuery.matches ? 'coarse' : 'fine';
262
- this._pointerQuery.addEventListener('change', this._onPointerChange);
261
+ this.listen(this._pointerQuery, 'change', this._onPointerChange as EventListener);
263
262
  }
264
263
 
265
264
  private _signal(store: Map<string, Signal<() => void>>, name: string): Signal<() => void> {
@@ -298,7 +298,7 @@ export class CaptionsPlugin extends Plugin<CaptionsOptions> implements ICaptions
298
298
  const RendererClass: CaptionRendererConstructor = this.options.renderer;
299
299
  this.renderer = this.view.addChild(new RendererClass(this)) as CaptionsRenderer;
300
300
 
301
- this.app.ticker.add(this.update);
301
+ this.addTickerCallback(this.update);
302
302
  this.app.scenes.onSceneChangeStart.connect(this.stopAllCaptions);
303
303
  }
304
304
 
@@ -74,23 +74,19 @@ export class GesturePlugin extends Plugin<GesturePluginOptions> implements IGest
74
74
  canvas.style.touchAction = 'none';
75
75
  }
76
76
 
77
- canvas.addEventListener('pointerdown', this._onPointerDown);
78
- window.addEventListener('pointermove', this._onPointerMove);
79
- window.addEventListener('pointerup', this._onPointerUp);
80
- window.addEventListener('pointercancel', this._onPointerCancel);
77
+ this.listen(canvas, 'pointerdown', this._onPointerDown as EventListener);
78
+ this.listen(window, 'pointermove', this._onPointerMove as EventListener);
79
+ this.listen(window, 'pointerup', this._onPointerUp as EventListener);
80
+ this.listen(window, 'pointercancel', this._onPointerCancel as EventListener);
81
81
  }
82
82
 
83
83
  public destroy(): void {
84
84
  if (this._canvas) {
85
- this._canvas.removeEventListener('pointerdown', this._onPointerDown);
86
85
  if (this._options.preventDefault) {
87
86
  this._canvas.style.touchAction = this._previousTouchAction;
88
87
  }
89
88
  this._canvas = null;
90
89
  }
91
- window.removeEventListener('pointermove', this._onPointerMove);
92
- window.removeEventListener('pointerup', this._onPointerUp);
93
- window.removeEventListener('pointercancel', this._onPointerCancel);
94
90
 
95
91
  this._pointers.clear();
96
92
  this._state = 'idle';