@caperjs/core 0.2.4 → 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 (208) hide show
  1. package/build/assetpack.mjs +90 -0
  2. package/build/index.mjs +3 -1
  3. package/build/internal/discovery.mjs +1 -1
  4. package/build/internal/manifest.mjs +8 -3
  5. package/build/internal/validate.mjs +3 -1
  6. package/build/plugins/assetTypes.mjs +4 -4
  7. package/build/plugins/caperConfig.mjs +12 -5
  8. package/build/plugins/lists.mjs +29 -9
  9. package/build/plugins/pruneFallbacks.mjs +25 -2
  10. package/cli/create.mjs +57 -6
  11. package/cli/create.test.mjs +36 -0
  12. package/cli/update.mjs +1 -1
  13. package/lib/{CaptionsPlugin-WPAcfAkx.js → CaptionsPlugin-cSJj-R9M.js} +5 -5
  14. package/lib/{CaptionsPlugin-WPAcfAkx.js.map → CaptionsPlugin-cSJj-R9M.js.map} +1 -1
  15. package/lib/{DataAdapter-DS-twowe.js → DataAdapter-D2tc6hx9.js} +15 -10
  16. package/lib/DataAdapter-D2tc6hx9.js.map +1 -0
  17. package/lib/{DebugRenderer-D1ryrRYx.js → DebugRenderer-CGUKy6Fy.js} +2 -2
  18. package/lib/{DebugRenderer-D1ryrRYx.js.map → DebugRenderer-CGUKy6Fy.js.map} +1 -1
  19. package/lib/{DevToolsPlugin-DH7zX7HU.js → DevToolsPlugin-B8GX3H9b.js} +2 -2
  20. package/lib/{DevToolsPlugin-DH7zX7HU.js.map → DevToolsPlugin-B8GX3H9b.js.map} +1 -1
  21. package/lib/{GSAPPlugin-BBtRjUqe.js → GSAPPlugin-Br9AjuTD.js} +6 -6
  22. package/lib/{GSAPPlugin-BBtRjUqe.js.map → GSAPPlugin-Br9AjuTD.js.map} +1 -1
  23. package/lib/{LayoutPlugin-Co6vcoV4.js → LayoutPlugin-BX3OP7pw.js} +4 -4
  24. package/lib/{LayoutPlugin-Co6vcoV4.js.map → LayoutPlugin-BX3OP7pw.js.map} +1 -1
  25. package/lib/{SpinePlugin-uN641Hmk.js → SpinePlugin-C-JDdXpf.js} +690 -793
  26. package/lib/SpinePlugin-C-JDdXpf.js.map +1 -0
  27. package/lib/{StatsPlugin-vZQRm6wh.js → StatsPlugin-yJlJtoQ2.js} +7 -7
  28. package/lib/StatsPlugin-yJlJtoQ2.js.map +1 -0
  29. package/lib/{VoiceOverPlugin-KlZdGj1G.js → VoiceOverPlugin-DjSZp4Ko.js} +7 -6
  30. package/lib/VoiceOverPlugin-DjSZp4Ko.js.map +1 -0
  31. package/lib/caper.mjs +159 -1917
  32. package/lib/caper.mjs.map +1 -1
  33. package/lib/const-C3sxeYhl.js +1746 -0
  34. package/lib/const-C3sxeYhl.js.map +1 -0
  35. package/lib/core/Application.d.ts +8 -3
  36. package/lib/core/Application.d.ts.map +1 -1
  37. package/lib/core/Application.test.d.ts +2 -0
  38. package/lib/core/Application.test.d.ts.map +1 -0
  39. package/lib/core/config.d.ts +6 -1
  40. package/lib/core/config.d.ts.map +1 -1
  41. package/lib/core/config.test.d.ts +2 -0
  42. package/lib/core/config.test.d.ts.map +1 -0
  43. package/lib/core/create.d.ts.map +1 -1
  44. package/lib/core/interfaces/IApplication.d.ts +6 -6
  45. package/lib/core/interfaces/IApplication.d.ts.map +1 -1
  46. package/lib/core/interfaces/ICoreSignals.d.ts +2 -1
  47. package/lib/core/interfaces/ICoreSignals.d.ts.map +1 -1
  48. package/lib/display/Camera.test.d.ts +2 -0
  49. package/lib/display/Camera.test.d.ts.map +1 -0
  50. package/lib/display/Entity.d.ts +2 -2
  51. package/lib/display/SceneTransition.test.d.ts +1 -0
  52. package/lib/display/SceneTransition.test.d.ts.map +1 -0
  53. package/lib/display/SpineAnimation.d.ts +3 -3
  54. package/lib/mixins/factory/methods.d.ts.map +1 -1
  55. package/lib/padding-1kHDpGlQ.js +41 -0
  56. package/lib/padding-1kHDpGlQ.js.map +1 -0
  57. package/lib/plugins/AssetsPlugin.d.ts.map +1 -1
  58. package/lib/plugins/AssetsPlugin.test.d.ts +2 -0
  59. package/lib/plugins/AssetsPlugin.test.d.ts.map +1 -0
  60. package/lib/plugins/DataAdapter.d.ts.map +1 -1
  61. package/lib/plugins/DataAdapter.test.d.ts +2 -0
  62. package/lib/plugins/DataAdapter.test.d.ts.map +1 -0
  63. package/lib/plugins/FullScreenPlugin.d.ts +0 -5
  64. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  65. package/lib/plugins/FullScreenPlugin.test.d.ts +2 -0
  66. package/lib/plugins/FullScreenPlugin.test.d.ts.map +1 -0
  67. package/lib/plugins/GSAPPlugin.d.ts +0 -2
  68. package/lib/plugins/GSAPPlugin.d.ts.map +1 -1
  69. package/lib/plugins/GSAPPlugin.test.d.ts +2 -0
  70. package/lib/plugins/GSAPPlugin.test.d.ts.map +1 -0
  71. package/lib/plugins/KeyboardPlugin.d.ts +0 -1
  72. package/lib/plugins/KeyboardPlugin.d.ts.map +1 -1
  73. package/lib/plugins/LookupPlugin.d.ts +6 -1
  74. package/lib/plugins/LookupPlugin.d.ts.map +1 -1
  75. package/lib/plugins/LookupPlugin.test.d.ts +2 -0
  76. package/lib/plugins/LookupPlugin.test.d.ts.map +1 -0
  77. package/lib/plugins/Plugin.d.ts +101 -2
  78. package/lib/plugins/Plugin.d.ts.map +1 -1
  79. package/lib/plugins/PopupManagerPlugin.d.ts.map +1 -1
  80. package/lib/plugins/PopupManagerPlugin.test.d.ts +1 -0
  81. package/lib/plugins/PopupManagerPlugin.test.d.ts.map +1 -0
  82. package/lib/plugins/SceneManagerPlugin.d.ts +1 -1
  83. package/lib/plugins/SceneManagerPlugin.d.ts.map +1 -1
  84. package/lib/plugins/SceneManagerPlugin.test.d.ts +2 -0
  85. package/lib/plugins/SceneManagerPlugin.test.d.ts.map +1 -0
  86. package/lib/plugins/StatsPlugin.d.ts.map +1 -1
  87. package/lib/plugins/TimerPlugin.d.ts.map +1 -1
  88. package/lib/plugins/WebEventsPlugin.d.ts +0 -1
  89. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  90. package/lib/plugins/actions/ActionsPlugin.test.d.ts +2 -0
  91. package/lib/plugins/actions/ActionsPlugin.test.d.ts.map +1 -0
  92. package/lib/plugins/audio/AudioChannel.d.ts.map +1 -1
  93. package/lib/plugins/audio/AudioManagerPlugin.d.ts.map +1 -1
  94. package/lib/plugins/audio/AudioManagerPlugin.test.d.ts +2 -0
  95. package/lib/plugins/audio/AudioManagerPlugin.test.d.ts.map +1 -0
  96. package/lib/plugins/audio/VoiceOverPlugin.d.ts.map +1 -1
  97. package/lib/plugins/breakpoints/BreakpointPlugin.d.ts.map +1 -1
  98. package/lib/plugins/captions/CaptionsPlugin.test.d.ts +2 -0
  99. package/lib/plugins/captions/CaptionsPlugin.test.d.ts.map +1 -0
  100. package/lib/plugins/focus/FocusManagerPlugin.d.ts.map +1 -1
  101. package/lib/plugins/focus/FocusManagerPlugin.test.d.ts +2 -0
  102. package/lib/plugins/focus/FocusManagerPlugin.test.d.ts.map +1 -0
  103. package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -1
  104. package/lib/plugins/input/AbstractControls.d.ts +47 -3
  105. package/lib/plugins/input/AbstractControls.d.ts.map +1 -1
  106. package/lib/plugins/input/Controls.test.d.ts +2 -0
  107. package/lib/plugins/input/Controls.test.d.ts.map +1 -0
  108. package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
  109. package/lib/plugins/input/controlsCore.d.ts +58 -0
  110. package/lib/plugins/input/controlsCore.d.ts.map +1 -0
  111. package/lib/plugins/input/controlsCore.test.d.ts +2 -0
  112. package/lib/plugins/input/controlsCore.test.d.ts.map +1 -0
  113. package/lib/plugins/input/keyboard/KeyboardControls.d.ts +15 -0
  114. package/lib/plugins/input/keyboard/KeyboardControls.d.ts.map +1 -0
  115. package/lib/plugins/input/keyboard/index.d.ts +1 -1
  116. package/lib/plugins/input/keyboard/index.d.ts.map +1 -1
  117. package/lib/plugins/input/touch/VirtualControls.d.ts +8 -14
  118. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  119. package/lib/plugins/input/touch/VirtualControls.test.d.ts +2 -0
  120. package/lib/plugins/input/touch/VirtualControls.test.d.ts.map +1 -0
  121. package/lib/{registries-BLrxALZ0.js → registries-BfV-VF7a.js} +665 -597
  122. package/lib/registries-BfV-VF7a.js.map +1 -0
  123. package/lib/store/Store.d.ts.map +1 -1
  124. package/lib/ui/Button.d.ts.map +1 -1
  125. package/lib/ui/UICanvas.d.ts +10 -1
  126. package/lib/ui/UICanvas.d.ts.map +1 -1
  127. package/lib/ui/UICanvas.test.d.ts +0 -1
  128. package/lib/ui/UICanvas.test.d.ts.map +1 -1
  129. package/lib/utils/color.d.ts.map +1 -1
  130. package/lib/utils/color.test.d.ts +2 -0
  131. package/lib/utils/color.test.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/src/core/Application.test.ts +168 -0
  134. package/src/core/Application.ts +37 -27
  135. package/src/core/config.test.ts +79 -0
  136. package/src/core/config.ts +28 -6
  137. package/src/core/create.ts +11 -0
  138. package/src/core/interfaces/IApplication.ts +6 -6
  139. package/src/core/interfaces/ICoreSignals.ts +2 -0
  140. package/src/display/Camera.test.ts +40 -0
  141. package/src/display/Camera.ts +3 -3
  142. package/src/display/Entity.ts +2 -2
  143. package/src/display/SceneTransition.test.ts +58 -0
  144. package/src/display/SceneTransition.ts +1 -1
  145. package/src/display/SpineAnimation.ts +4 -4
  146. package/src/mixins/factory/methods.ts +0 -1
  147. package/src/plugins/AssetsPlugin.test.ts +48 -0
  148. package/src/plugins/AssetsPlugin.ts +3 -1
  149. package/src/plugins/DataAdapter.test.ts +32 -0
  150. package/src/plugins/DataAdapter.ts +5 -1
  151. package/src/plugins/FullScreenPlugin.test.ts +48 -0
  152. package/src/plugins/FullScreenPlugin.ts +9 -18
  153. package/src/plugins/GSAPPlugin.test.ts +31 -0
  154. package/src/plugins/GSAPPlugin.ts +1 -4
  155. package/src/plugins/KeyboardPlugin.ts +3 -10
  156. package/src/plugins/LookupPlugin.test.ts +42 -0
  157. package/src/plugins/LookupPlugin.ts +15 -3
  158. package/src/plugins/Plugin.test.ts +130 -2
  159. package/src/plugins/Plugin.ts +159 -3
  160. package/src/plugins/PopupManagerPlugin.test.ts +53 -0
  161. package/src/plugins/PopupManagerPlugin.ts +5 -3
  162. package/src/plugins/SceneManagerPlugin.test.ts +76 -0
  163. package/src/plugins/SceneManagerPlugin.ts +18 -13
  164. package/src/plugins/StatsPlugin.ts +3 -2
  165. package/src/plugins/TimerPlugin.ts +1 -2
  166. package/src/plugins/WebEventsPlugin.test.ts +32 -0
  167. package/src/plugins/WebEventsPlugin.ts +9 -16
  168. package/src/plugins/actions/ActionsPlugin.test.ts +68 -0
  169. package/src/plugins/actions/ActionsPlugin.ts +5 -5
  170. package/src/plugins/audio/AudioChannel.test.ts +19 -0
  171. package/src/plugins/audio/AudioChannel.ts +8 -1
  172. package/src/plugins/audio/AudioManagerPlugin.test.ts +41 -0
  173. package/src/plugins/audio/AudioManagerPlugin.ts +3 -2
  174. package/src/plugins/audio/VoiceOverPlugin.ts +1 -0
  175. package/src/plugins/breakpoints/BreakpointPlugin.ts +1 -2
  176. package/src/plugins/captions/CaptionsPlugin.test.ts +40 -0
  177. package/src/plugins/captions/CaptionsPlugin.ts +2 -2
  178. package/src/plugins/focus/FocusManagerPlugin.test.ts +71 -0
  179. package/src/plugins/focus/FocusManagerPlugin.ts +7 -0
  180. package/src/plugins/gesture/GesturePlugin.ts +4 -8
  181. package/src/plugins/input/AbstractControls.ts +104 -3
  182. package/src/plugins/input/Controls.test.ts +205 -0
  183. package/src/plugins/input/InputPlugin.ts +6 -14
  184. package/src/plugins/input/controlsCore.test.ts +151 -0
  185. package/src/plugins/input/controlsCore.ts +162 -0
  186. package/src/plugins/input/keyboard/KeyboardControls.ts +84 -0
  187. package/src/plugins/input/keyboard/index.ts +1 -1
  188. package/src/plugins/input/touch/VirtualControls.test.ts +38 -0
  189. package/src/plugins/input/touch/VirtualControls.ts +53 -120
  190. package/src/plugins/spine/SpinePlugin.ts +2 -2
  191. package/src/store/Store.test.ts +15 -0
  192. package/src/store/Store.ts +4 -1
  193. package/src/ui/Button.test.ts +12 -0
  194. package/src/ui/Button.ts +1 -0
  195. package/src/ui/UICanvas.test.ts +109 -6
  196. package/src/ui/UICanvas.ts +41 -3
  197. package/src/utils/color.test.ts +16 -0
  198. package/src/utils/color.ts +2 -1
  199. package/src/utils/platform.ts +1 -1
  200. package/src/version.ts +1 -1
  201. package/lib/DataAdapter-DS-twowe.js.map +0 -1
  202. package/lib/SpinePlugin-uN641Hmk.js.map +0 -1
  203. package/lib/StatsPlugin-vZQRm6wh.js.map +0 -1
  204. package/lib/VoiceOverPlugin-KlZdGj1G.js.map +0 -1
  205. package/lib/plugins/input/keyboard/KeyboardContols.d.ts +0 -28
  206. package/lib/plugins/input/keyboard/KeyboardContols.d.ts.map +0 -1
  207. package/lib/registries-BLrxALZ0.js.map +0 -1
  208. package/src/plugins/input/keyboard/KeyboardContols.ts +0 -182
@@ -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
@@ -0,0 +1,53 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mockApp = vi.hoisted(() => ({
4
+ ticker: { addOnce: (cb: () => void) => cb() },
5
+ }));
6
+
7
+ // PopupManagerPlugin transitively imports Application → Pixi display graph. Stub it.
8
+ vi.mock('../core', () => ({
9
+ coreFunctionRegistry: {},
10
+ coreSignalRegistry: {},
11
+ }));
12
+ vi.mock('../core/Application', () => ({
13
+ Application: { getInstance: () => mockApp },
14
+ }));
15
+
16
+ // Evaluating the table registers it into mixins/factory/defaults, which the
17
+ // plugin's `view` Container reads lazily on construction.
18
+ import '../mixins/factory/const';
19
+ import { PopupManagerPlugin } from './PopupManagerPlugin';
20
+
21
+ function makeFakePopup() {
22
+ return {
23
+ beforeHide: vi.fn(),
24
+ hide: vi.fn().mockResolvedValue(undefined),
25
+ end: vi.fn(),
26
+ restoreActionContext: vi.fn(),
27
+ };
28
+ }
29
+
30
+ describe('PopupManagerPlugin removeAllPopups', () => {
31
+ let plugin: PopupManagerPlugin;
32
+
33
+ beforeEach(() => {
34
+ plugin = new PopupManagerPlugin();
35
+ vi.spyOn(plugin.view, 'removeChild').mockReturnValue(undefined as never);
36
+ });
37
+
38
+ it('clears active popups and the current id when animating', async () => {
39
+ const first = makeFakePopup();
40
+ const second = makeFakePopup();
41
+ (plugin as any)._activePopups.set('first', first);
42
+ (plugin as any)._activePopups.set('second', second);
43
+ (plugin as any)._currentPopupId = 'second';
44
+
45
+ plugin.removeAllPopups(true);
46
+ await new Promise((resolve) => setTimeout(resolve, 0));
47
+
48
+ expect(first.hide).toHaveBeenCalledTimes(1);
49
+ expect(second.hide).toHaveBeenCalledTimes(1);
50
+ expect(plugin.hasActivePopups).toBe(false);
51
+ expect(plugin.currentPopupId).toBeUndefined();
52
+ });
53
+ });
@@ -179,9 +179,11 @@ export class PopupManagerPlugin extends Plugin implements IPopupManagerPlugin {
179
179
  */
180
180
  removeAllPopups(animate: boolean = false): void {
181
181
  if (animate) {
182
- this._activePopups.forEach((popup) => {
183
- popup.hide();
184
- });
182
+ // reuse the single-popup hide path so the view, the active map and the
183
+ // current id get cleaned up once each hide animation resolves
184
+ for (const id of [...this._activePopups.keys()]) {
185
+ void this.hidePopup(id);
186
+ }
185
187
  } else {
186
188
  this._activePopups.clear();
187
189
  this.view.removeChildren();
@@ -0,0 +1,76 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { Signal } from '../signals';
4
+
5
+ const mockApp = vi.hoisted(() => ({
6
+ config: {} as Record<string, unknown>,
7
+ onPause: null as any,
8
+ onResume: null as any,
9
+ }));
10
+
11
+ // SceneManagerPlugin transitively imports Application → Pixi display graph. Stub it.
12
+ vi.mock('../core', () => ({
13
+ coreFunctionRegistry: {},
14
+ coreSignalRegistry: {},
15
+ }));
16
+ vi.mock('../core/Application', () => ({
17
+ Application: { getInstance: () => mockApp, containerElement: null },
18
+ }));
19
+
20
+ import { SceneManagerPlugin } from './SceneManagerPlugin';
21
+
22
+ describe('SceneManagerPlugin destroy', () => {
23
+ let plugin: SceneManagerPlugin;
24
+
25
+ beforeEach(() => {
26
+ mockApp.config = { showSceneDebugMenu: false, useHash: true };
27
+ mockApp.onPause = new Signal<(config: any) => void>();
28
+ mockApp.onResume = new Signal<(config: any) => void>();
29
+ (globalThis as any).Caper = { get: () => [] };
30
+ plugin = new SceneManagerPlugin();
31
+ });
32
+
33
+ afterEach(() => {
34
+ delete (globalThis as any).Caper;
35
+ window.location.hash = '';
36
+ });
37
+
38
+ it('stops responding to hashchange after destroy', async () => {
39
+ await plugin.initialize({}, mockApp as never);
40
+ const spy = vi.spyOn(plugin, 'getSceneFromHash').mockReturnValue(null);
41
+
42
+ window.dispatchEvent(new Event('hashchange'));
43
+ expect(spy).toHaveBeenCalledTimes(1);
44
+
45
+ plugin.destroy();
46
+ window.dispatchEvent(new Event('hashchange'));
47
+ expect(spy).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ it('stops forwarding pause/resume to the current scene after destroy', async () => {
51
+ await plugin.initialize({}, mockApp as never);
52
+ const onPause = vi.fn();
53
+ const onResume = vi.fn();
54
+ plugin.currentScene = { onPause, onResume } as never;
55
+
56
+ mockApp.onPause.emit({});
57
+ mockApp.onResume.emit({});
58
+ expect(onPause).toHaveBeenCalledTimes(1);
59
+ expect(onResume).toHaveBeenCalledTimes(1);
60
+
61
+ plugin.destroy();
62
+ mockApp.onPause.emit({});
63
+ mockApp.onResume.emit({});
64
+ expect(onPause).toHaveBeenCalledTimes(1);
65
+ expect(onResume).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it('removes the debug menu from the DOM after destroy', async () => {
69
+ mockApp.config = { showSceneDebugMenu: true };
70
+ await plugin.initialize({}, mockApp as never);
71
+ expect(document.getElementById('scene-debug')).not.toBeNull();
72
+
73
+ plugin.destroy();
74
+ expect(document.getElementById('scene-debug')).toBeNull();
75
+ });
76
+ });
@@ -139,8 +139,6 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
139
139
  this._defaultLoadMethod = method;
140
140
  }
141
141
 
142
- public destroy(): void {}
143
-
144
142
  public async initialize(_options: any, app: IApplication): Promise<void> {
145
143
  this._debugVisible =
146
144
  this.app.config?.showSceneDebugMenu === true || (isDev && this.app.config?.showSceneDebugMenu !== false);
@@ -179,8 +177,10 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
179
177
  this._listenForHashChange();
180
178
  }
181
179
 
182
- this.app.onPause.connect(this._onPause, 'highest');
183
- this.app.onResume.connect(this._onResume, 'highest');
180
+ this.addSignalConnection(
181
+ this.app.onPause.connect(this._onPause, 'highest'),
182
+ this.app.onResume.connect(this._onResume, 'highest'),
183
+ );
184
184
 
185
185
  return Promise.resolve(undefined);
186
186
  }
@@ -368,12 +368,14 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
368
368
  }
369
369
 
370
370
  private _listenForHashChange() {
371
- window.addEventListener('hashchange', () => {
372
- const sceneId = this.getSceneFromHash();
373
- if (sceneId) {
374
- void this.loadScene(sceneId);
375
- }
376
- });
371
+ this.listen(window, 'hashchange', this._onHashChange);
372
+ }
373
+
374
+ private _onHashChange() {
375
+ const sceneId = this.getSceneFromHash();
376
+ if (sceneId) {
377
+ void this.loadScene(sceneId);
378
+ }
377
379
  }
378
380
 
379
381
  private async _createCurrentScene() {
@@ -569,6 +571,7 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
569
571
  this._debugMenu.appendChild(icon);
570
572
 
571
573
  (Application.containerElement || document.body).appendChild(this._debugMenu);
574
+ this.addDisposer(() => this._debugMenu?.parentElement?.removeChild(this._debugMenu));
572
575
 
573
576
  this._sceneSelect = document.createElement('select');
574
577
  this._sceneSelect.style.cssText =
@@ -643,7 +646,7 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
643
646
 
644
647
  this._debugMenu.appendChild(this._sceneSelect);
645
648
 
646
- this._debugMenu.addEventListener('change', (e: Event) => {
649
+ this.listen(this._debugMenu, 'change', (e: Event) => {
647
650
  if (this._queue) {
648
651
  e.preventDefault();
649
652
  return;
@@ -655,8 +658,10 @@ export class SceneManagerPlugin extends Plugin implements ISceneManagerPlugin {
655
658
  }
656
659
  });
657
660
 
658
- this.onSceneChangeStart.connect(this._disableDebugMenu);
659
- this.onSceneChangeComplete.connect(this._enableDebugMenu);
661
+ this.addSignalConnection(
662
+ this.onSceneChangeStart.connect(this._disableDebugMenu),
663
+ this.onSceneChangeComplete.connect(this._enableDebugMenu),
664
+ );
660
665
  }
661
666
 
662
667
  private _enableDebugMenu() {
@@ -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,6 +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
+ this.addTickerCallback(this.stats.update, this.stats, UPDATE_PRIORITY.UTILITY);
23
24
  }
24
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();
@@ -9,6 +9,7 @@ vi.mock('../core/Application', () => ({
9
9
  Application: { getInstance: () => ({ renderer: { canvas: null } }) },
10
10
  }));
11
11
 
12
+ import { Signal } from '../signals';
12
13
  import { WebEventsPlugin } from './WebEventsPlugin';
13
14
 
14
15
  type FakeVisualViewport = EventTarget & { scale: number };
@@ -73,3 +74,34 @@ describe('WebEventsPlugin visualViewport resize', () => {
73
74
  expect(() => plugin.initialize()).not.toThrow();
74
75
  });
75
76
  });
77
+
78
+ describe('WebEventsPlugin destroy', () => {
79
+ let plugin: WebEventsPlugin;
80
+
81
+ beforeEach(() => {
82
+ plugin = new WebEventsPlugin();
83
+ });
84
+
85
+ it('stops handling orientationchange after destroy', () => {
86
+ const spy = vi.spyOn(plugin as any, '_onOrientationChanged');
87
+ plugin.initialize();
88
+
89
+ window.dispatchEvent(new Event('orientationchange'));
90
+ expect(spy).toHaveBeenCalledTimes(1);
91
+
92
+ plugin.destroy();
93
+ window.dispatchEvent(new Event('orientationchange'));
94
+ expect(spy).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('disconnects tracked signal connections on destroy', () => {
98
+ const signal = new Signal<() => void>();
99
+ const handler = vi.fn();
100
+ plugin.addSignalConnection(signal.connect(handler));
101
+
102
+ plugin.destroy();
103
+ signal.emit();
104
+
105
+ expect(handler).not.toHaveBeenCalled();
106
+ });
107
+ });