@caperjs/core 0.2.3 → 0.3.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 (175) hide show
  1. package/build/index.mjs +3 -1
  2. package/build/internal/discovery.mjs +1 -1
  3. package/build/internal/manifest.mjs +8 -3
  4. package/build/internal/validate.mjs +3 -1
  5. package/build/plugins/assetTypes.mjs +4 -4
  6. package/build/plugins/caperConfig.mjs +12 -5
  7. package/build/plugins/lists.mjs +29 -9
  8. package/cli/create.mjs +57 -6
  9. package/cli/create.test.mjs +36 -0
  10. package/cli/update.mjs +1 -1
  11. package/lib/{CaptionsPlugin-WPAcfAkx.js → CaptionsPlugin-BLbLl3WB.js} +3 -3
  12. package/lib/{CaptionsPlugin-WPAcfAkx.js.map → CaptionsPlugin-BLbLl3WB.js.map} +1 -1
  13. package/lib/{DataAdapter-DS-twowe.js → DataAdapter-aHSNM_26.js} +12 -7
  14. package/lib/DataAdapter-aHSNM_26.js.map +1 -0
  15. package/lib/{DebugRenderer-D1ryrRYx.js → DebugRenderer-8LjHtBzA.js} +2 -2
  16. package/lib/{DebugRenderer-D1ryrRYx.js.map → DebugRenderer-8LjHtBzA.js.map} +1 -1
  17. package/lib/{DevToolsPlugin-DH7zX7HU.js → DevToolsPlugin-Vrk6aOtS.js} +2 -2
  18. package/lib/{DevToolsPlugin-DH7zX7HU.js.map → DevToolsPlugin-Vrk6aOtS.js.map} +1 -1
  19. package/lib/{GSAPPlugin-BBtRjUqe.js → GSAPPlugin-Hm3F4zdg.js} +3 -3
  20. package/lib/{GSAPPlugin-BBtRjUqe.js.map → GSAPPlugin-Hm3F4zdg.js.map} +1 -1
  21. package/lib/{LayoutPlugin-Co6vcoV4.js → LayoutPlugin-Dpgeawfw.js} +4 -4
  22. package/lib/{LayoutPlugin-Co6vcoV4.js.map → LayoutPlugin-Dpgeawfw.js.map} +1 -1
  23. package/lib/{SpinePlugin-uN641Hmk.js → SpinePlugin-B08yAw1S.js} +690 -793
  24. package/lib/SpinePlugin-B08yAw1S.js.map +1 -0
  25. package/lib/{StatsPlugin-vZQRm6wh.js → StatsPlugin-D2Xa5j97.js} +5 -2
  26. package/lib/StatsPlugin-D2Xa5j97.js.map +1 -0
  27. package/lib/{VoiceOverPlugin-KlZdGj1G.js → VoiceOverPlugin-qtFlqpRn.js} +6 -5
  28. package/lib/VoiceOverPlugin-qtFlqpRn.js.map +1 -0
  29. package/lib/caper.mjs +37 -19
  30. package/lib/caper.mjs.map +1 -1
  31. package/lib/core/Application.d.ts +8 -3
  32. package/lib/core/Application.d.ts.map +1 -1
  33. package/lib/core/Application.test.d.ts +2 -0
  34. package/lib/core/Application.test.d.ts.map +1 -0
  35. package/lib/core/config.d.ts +6 -1
  36. package/lib/core/config.d.ts.map +1 -1
  37. package/lib/core/config.test.d.ts +2 -0
  38. package/lib/core/config.test.d.ts.map +1 -0
  39. package/lib/core/interfaces/IApplication.d.ts +6 -6
  40. package/lib/core/interfaces/IApplication.d.ts.map +1 -1
  41. package/lib/core/interfaces/ICoreSignals.d.ts +2 -1
  42. package/lib/core/interfaces/ICoreSignals.d.ts.map +1 -1
  43. package/lib/display/Camera.test.d.ts +2 -0
  44. package/lib/display/Camera.test.d.ts.map +1 -0
  45. package/lib/display/Entity.d.ts +2 -2
  46. package/lib/display/SceneTransition.test.d.ts +1 -0
  47. package/lib/display/SceneTransition.test.d.ts.map +1 -0
  48. package/lib/display/SpineAnimation.d.ts +3 -3
  49. package/lib/mixins/factory/methods.d.ts.map +1 -1
  50. package/lib/plugins/AssetsPlugin.d.ts.map +1 -1
  51. package/lib/plugins/AssetsPlugin.test.d.ts +2 -0
  52. package/lib/plugins/AssetsPlugin.test.d.ts.map +1 -0
  53. package/lib/plugins/DataAdapter.d.ts.map +1 -1
  54. package/lib/plugins/DataAdapter.test.d.ts +2 -0
  55. package/lib/plugins/DataAdapter.test.d.ts.map +1 -0
  56. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  57. package/lib/plugins/FullScreenPlugin.test.d.ts +2 -0
  58. package/lib/plugins/FullScreenPlugin.test.d.ts.map +1 -0
  59. package/lib/plugins/GSAPPlugin.d.ts +0 -2
  60. package/lib/plugins/GSAPPlugin.d.ts.map +1 -1
  61. package/lib/plugins/GSAPPlugin.test.d.ts +2 -0
  62. package/lib/plugins/GSAPPlugin.test.d.ts.map +1 -0
  63. package/lib/plugins/LookupPlugin.d.ts +6 -1
  64. package/lib/plugins/LookupPlugin.d.ts.map +1 -1
  65. package/lib/plugins/LookupPlugin.test.d.ts +2 -0
  66. package/lib/plugins/LookupPlugin.test.d.ts.map +1 -0
  67. package/lib/plugins/PopupManagerPlugin.d.ts.map +1 -1
  68. package/lib/plugins/PopupManagerPlugin.test.d.ts +1 -0
  69. package/lib/plugins/PopupManagerPlugin.test.d.ts.map +1 -0
  70. package/lib/plugins/SceneManagerPlugin.d.ts +1 -0
  71. package/lib/plugins/SceneManagerPlugin.d.ts.map +1 -1
  72. package/lib/plugins/SceneManagerPlugin.test.d.ts +2 -0
  73. package/lib/plugins/SceneManagerPlugin.test.d.ts.map +1 -0
  74. package/lib/plugins/StatsPlugin.d.ts +1 -0
  75. package/lib/plugins/StatsPlugin.d.ts.map +1 -1
  76. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  77. package/lib/plugins/actions/ActionsPlugin.test.d.ts +2 -0
  78. package/lib/plugins/actions/ActionsPlugin.test.d.ts.map +1 -0
  79. package/lib/plugins/audio/AudioChannel.d.ts.map +1 -1
  80. package/lib/plugins/audio/AudioManagerPlugin.d.ts.map +1 -1
  81. package/lib/plugins/audio/AudioManagerPlugin.test.d.ts +2 -0
  82. package/lib/plugins/audio/AudioManagerPlugin.test.d.ts.map +1 -0
  83. package/lib/plugins/audio/VoiceOverPlugin.d.ts.map +1 -1
  84. package/lib/plugins/captions/CaptionsPlugin.test.d.ts +2 -0
  85. package/lib/plugins/captions/CaptionsPlugin.test.d.ts.map +1 -0
  86. package/lib/plugins/focus/FocusManagerPlugin.d.ts.map +1 -1
  87. package/lib/plugins/focus/FocusManagerPlugin.test.d.ts +2 -0
  88. package/lib/plugins/focus/FocusManagerPlugin.test.d.ts.map +1 -0
  89. package/lib/plugins/input/AbstractControls.d.ts +5 -0
  90. package/lib/plugins/input/AbstractControls.d.ts.map +1 -1
  91. package/lib/plugins/input/Controls.test.d.ts +2 -0
  92. package/lib/plugins/input/Controls.test.d.ts.map +1 -0
  93. package/lib/plugins/input/keyboard/KeyboardContols.d.ts +5 -0
  94. package/lib/plugins/input/keyboard/KeyboardContols.d.ts.map +1 -1
  95. package/lib/plugins/input/touch/VirtualControls.d.ts +6 -0
  96. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  97. package/lib/plugins/input/touch/VirtualControls.test.d.ts +2 -0
  98. package/lib/plugins/input/touch/VirtualControls.test.d.ts.map +1 -0
  99. package/lib/{registries-BLrxALZ0.js → registries-m2vKTIih.js} +156 -101
  100. package/lib/registries-m2vKTIih.js.map +1 -0
  101. package/lib/store/Store.d.ts.map +1 -1
  102. package/lib/ui/Button.d.ts +8 -1
  103. package/lib/ui/Button.d.ts.map +1 -1
  104. package/lib/ui/Button.test.d.ts +2 -0
  105. package/lib/ui/Button.test.d.ts.map +1 -0
  106. package/lib/ui/UICanvas.d.ts +10 -1
  107. package/lib/ui/UICanvas.d.ts.map +1 -1
  108. package/lib/ui/UICanvas.test.d.ts +0 -1
  109. package/lib/ui/UICanvas.test.d.ts.map +1 -1
  110. package/lib/utils/color.d.ts.map +1 -1
  111. package/lib/utils/color.test.d.ts +2 -0
  112. package/lib/utils/color.test.d.ts.map +1 -0
  113. package/package.json +1 -1
  114. package/src/core/Application.test.ts +168 -0
  115. package/src/core/Application.ts +37 -27
  116. package/src/core/config.test.ts +79 -0
  117. package/src/core/config.ts +28 -6
  118. package/src/core/interfaces/IApplication.ts +6 -6
  119. package/src/core/interfaces/ICoreSignals.ts +2 -0
  120. package/src/display/Camera.test.ts +40 -0
  121. package/src/display/Camera.ts +3 -3
  122. package/src/display/Entity.ts +2 -2
  123. package/src/display/SceneTransition.test.ts +58 -0
  124. package/src/display/SceneTransition.ts +1 -1
  125. package/src/display/SpineAnimation.ts +4 -4
  126. package/src/mixins/factory/methods.ts +0 -1
  127. package/src/plugins/AssetsPlugin.test.ts +48 -0
  128. package/src/plugins/AssetsPlugin.ts +3 -1
  129. package/src/plugins/DataAdapter.test.ts +32 -0
  130. package/src/plugins/DataAdapter.ts +5 -1
  131. package/src/plugins/FullScreenPlugin.test.ts +48 -0
  132. package/src/plugins/FullScreenPlugin.ts +5 -1
  133. package/src/plugins/GSAPPlugin.test.ts +31 -0
  134. package/src/plugins/GSAPPlugin.ts +1 -4
  135. package/src/plugins/LookupPlugin.test.ts +42 -0
  136. package/src/plugins/LookupPlugin.ts +15 -3
  137. package/src/plugins/PopupManagerPlugin.test.ts +53 -0
  138. package/src/plugins/PopupManagerPlugin.ts +5 -3
  139. package/src/plugins/SceneManagerPlugin.test.ts +76 -0
  140. package/src/plugins/SceneManagerPlugin.ts +21 -11
  141. package/src/plugins/StatsPlugin.ts +8 -0
  142. package/src/plugins/WebEventsPlugin.test.ts +32 -0
  143. package/src/plugins/WebEventsPlugin.ts +2 -0
  144. package/src/plugins/actions/ActionsPlugin.test.ts +68 -0
  145. package/src/plugins/actions/ActionsPlugin.ts +5 -5
  146. package/src/plugins/audio/AudioChannel.test.ts +19 -0
  147. package/src/plugins/audio/AudioChannel.ts +8 -1
  148. package/src/plugins/audio/AudioManagerPlugin.test.ts +41 -0
  149. package/src/plugins/audio/AudioManagerPlugin.ts +3 -2
  150. package/src/plugins/audio/VoiceOverPlugin.ts +1 -0
  151. package/src/plugins/captions/CaptionsPlugin.test.ts +40 -0
  152. package/src/plugins/captions/CaptionsPlugin.ts +1 -1
  153. package/src/plugins/focus/FocusManagerPlugin.test.ts +71 -0
  154. package/src/plugins/focus/FocusManagerPlugin.ts +7 -0
  155. package/src/plugins/input/AbstractControls.ts +6 -0
  156. package/src/plugins/input/Controls.test.ts +149 -0
  157. package/src/plugins/input/keyboard/KeyboardContols.ts +29 -5
  158. package/src/plugins/input/touch/VirtualControls.test.ts +38 -0
  159. package/src/plugins/input/touch/VirtualControls.ts +44 -10
  160. package/src/plugins/spine/SpinePlugin.ts +2 -2
  161. package/src/store/Store.test.ts +15 -0
  162. package/src/store/Store.ts +4 -1
  163. package/src/ui/Button.test.ts +248 -0
  164. package/src/ui/Button.ts +42 -10
  165. package/src/ui/UICanvas.test.ts +109 -6
  166. package/src/ui/UICanvas.ts +41 -3
  167. package/src/utils/color.test.ts +16 -0
  168. package/src/utils/color.ts +2 -1
  169. package/src/utils/platform.ts +1 -1
  170. package/src/version.ts +1 -1
  171. package/lib/DataAdapter-DS-twowe.js.map +0 -1
  172. package/lib/SpinePlugin-uN641Hmk.js.map +0 -1
  173. package/lib/StatsPlugin-vZQRm6wh.js.map +0 -1
  174. package/lib/VoiceOverPlugin-KlZdGj1G.js.map +0 -1
  175. package/lib/registries-BLrxALZ0.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { Application } from '../../../core/Application';
2
2
  import { WithSignals } from '../../../mixins/signals';
3
- import { bindAllMethods } from '../../../utils';
3
+ import { bindAllMethods, Logger } from '../../../utils';
4
4
  import type { Action } from '../../actions';
5
5
  import { normalizeKey, type KeyboardEventDetail } from '../../KeyboardPlugin';
6
6
  import { AbstractControls } from '../AbstractControls';
@@ -15,6 +15,7 @@ export class KeyboardControls extends WithSignals(AbstractControls) {
15
15
  private _keyCombinationsMap: Map<string[], Action> = new Map();
16
16
  private _activeDownKeys: Map<string, Action> = new Map();
17
17
  private _activeUpKeys: Map<string, Action> = new Map();
18
+ private _warnedMissingActions: Set<string> = new Set();
18
19
 
19
20
  constructor() {
20
21
  super();
@@ -31,11 +32,9 @@ export class KeyboardControls extends WithSignals(AbstractControls) {
31
32
  return false;
32
33
  }
33
34
  if (Array.isArray(controlsAction)) {
34
- return (
35
- this._keyCombinationsMap.has(controlsAction) || controlsAction.some((key) => this._singleDownKeys.has(key))
36
- );
35
+ return controlsAction.some((key) => this._isInputActive(key));
37
36
  } else {
38
- return this._singleDownKeys.has(controlsAction);
37
+ return this._isInputActive(controlsAction);
39
38
  }
40
39
  }
41
40
 
@@ -57,6 +56,27 @@ export class KeyboardControls extends WithSignals(AbstractControls) {
57
56
  this.app.ticker.add(this._update);
58
57
  }
59
58
 
59
+ public destroy(): void {
60
+ this.app.ticker.remove(this._update);
61
+ super.destroy();
62
+ }
63
+
64
+ /** A single key, or a `+` separated combination requiring every key to be down. */
65
+ private _isInputActive(input: string): boolean {
66
+ if (input.includes('+')) {
67
+ return input.split('+').every((key) => this._singleDownKeys.has(key));
68
+ }
69
+ return this._singleDownKeys.has(input);
70
+ }
71
+
72
+ private _warnMissingAction(key: string) {
73
+ if (this._warnedMissingActions.has(key)) {
74
+ return;
75
+ }
76
+ this._warnedMissingActions.add(key);
77
+ Logger.warn(`KeyboardControls: scheme references unknown action "${key}"`);
78
+ }
79
+
60
80
  private _sortActions() {
61
81
  const actions = this.app.actionsPlugin.getActions();
62
82
  this._keyCombinations = [];
@@ -68,6 +88,10 @@ export class KeyboardControls extends WithSignals(AbstractControls) {
68
88
  keys.forEach((key) => {
69
89
  const item = this._keyDownMap[key];
70
90
  const action = actions[key];
91
+ if (!action) {
92
+ this._warnMissingAction(key);
93
+ return;
94
+ }
71
95
  if (
72
96
  action.context !== '*' &&
73
97
  action.context !== this.app.actionContext &&
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // VirtualControls reaches the app through Application.getInstance(). Stub it.
4
+ // The stub app is filled in after the imports (getInstance is only called lazily).
5
+ const holder = vi.hoisted(() => ({ app: null as any }));
6
+ vi.mock('../../../core/Application', () => ({
7
+ Application: { getInstance: () => holder.app },
8
+ }));
9
+
10
+ import { Signal } from '../../../signals';
11
+ import { VirtualControls } from './VirtualControls';
12
+
13
+ describe('VirtualControls', () => {
14
+ it('stops reacting to action context changes after destroy', () => {
15
+ const onActionContextChanged = new Signal<(context: string) => void>();
16
+ holder.app = {
17
+ signal: { onActionContextChanged },
18
+ ticker: { add: vi.fn(), remove: vi.fn() },
19
+ actionsPlugin: { getActions: () => ({}) },
20
+ actionContext: 'default',
21
+ };
22
+
23
+ const controls = new VirtualControls();
24
+ const sortActions = vi.fn();
25
+ (controls as any)._sortActions = sortActions;
26
+
27
+ controls.initialize({});
28
+ const callsAfterInit = sortActions.mock.calls.length;
29
+
30
+ onActionContextChanged.emit('menu');
31
+ expect(sortActions).toHaveBeenCalledTimes(callsAfterInit + 1);
32
+
33
+ controls.destroy();
34
+
35
+ onActionContextChanged.emit('game');
36
+ expect(sortActions).toHaveBeenCalledTimes(callsAfterInit + 1);
37
+ });
38
+ });
@@ -1,9 +1,10 @@
1
- import { bindAllMethods } from '../../../utils';
1
+ import { bindAllMethods, Logger } from '../../../utils';
2
2
  import type { ControlsActionMap, JoystickControlsScheme, TouchControlsMap, TouchControlsScheme } from '../types';
3
3
 
4
4
  import { JoystickDirection } from '..';
5
5
  import { Application } from '../../../core/Application';
6
6
  import { WithSignals } from '../../../mixins/signals';
7
+ import type { SignalConnection } from '../../../signals';
7
8
  import type { IButton, IJoystick } from '../../../ui';
8
9
  import type { Action } from '../../actions';
9
10
  import { AbstractControls } from '../AbstractControls';
@@ -20,6 +21,8 @@ export class VirtualControls extends WithSignals(AbstractControls) {
20
21
  private _activeButtonDownIds: Map<string, Action> = new Map();
21
22
  private _activeButtonUpIds: Map<string, Action> = new Map();
22
23
  private _combinationsMap: Map<string[], Action> = new Map();
24
+ private _buttonConnections: Map<IButton, SignalConnection[]> = new Map();
25
+ private _warnedMissingActions: Set<string> = new Set();
23
26
 
24
27
  constructor() {
25
28
  super();
@@ -44,12 +47,14 @@ export class VirtualControls extends WithSignals(AbstractControls) {
44
47
  if (!button || this._buttons.has(button)) {
45
48
  return;
46
49
  }
47
- this.addSignalConnection(
50
+ const connections = [
48
51
  button.onDown.connect(() => this._handleButtonDown(button)),
49
52
  button.onUp.connect(() => this._handleButtonUp(button)),
50
53
  button.onUpOutside.connect(() => this._handleButtonUp(button)),
51
54
  button.onDestroy.connect(() => this.removeButton(button)),
52
- );
55
+ ];
56
+ this.addSignalConnection(...connections);
57
+ this._buttonConnections.set(button, connections);
53
58
  this._buttons.add(button);
54
59
  }
55
60
 
@@ -57,10 +62,10 @@ export class VirtualControls extends WithSignals(AbstractControls) {
57
62
  if (!button || !this._buttons.has(button)) {
58
63
  return;
59
64
  }
60
- button.onDown.disconnect(() => this._handleButtonDown(button));
61
- button.onUp.disconnect(() => this._handleButtonUp(button));
62
- button.onUpOutside.disconnect(() => this._handleButtonUp(button));
63
- button.onDestroy.disconnect(() => this.removeButton(button));
65
+ // signals disconnect by identity — disconnect the connections made in addButton
66
+ this._buttonConnections.get(button)?.forEach((connection) => connection.disconnect());
67
+ this._buttonConnections.delete(button);
68
+ this._singleDownButtons.delete(button.id!);
64
69
  this._buttons.delete(button);
65
70
  }
66
71
 
@@ -69,7 +74,7 @@ export class VirtualControls extends WithSignals(AbstractControls) {
69
74
  this._buttonDownMap = scheme.down || {};
70
75
  this._buttonUpMap = scheme.up || {};
71
76
  this._joystickMap = scheme.joystick || {};
72
- this.app.signal.onActionContextChanged.connect(this._sortActions);
77
+ this.addSignalConnection(this.app.signal.onActionContextChanged.connect(this._sortActions));
73
78
  this._sortActions();
74
79
  }
75
80
 
@@ -77,13 +82,18 @@ export class VirtualControls extends WithSignals(AbstractControls) {
77
82
  this.app.ticker.add(this._update);
78
83
  }
79
84
 
85
+ public destroy(): void {
86
+ this.app.ticker.remove(this._update);
87
+ super.destroy();
88
+ }
89
+
80
90
  isActionActive(action: Action): boolean {
81
91
  const buttonAction = this.scheme['down']?.[action] ?? null;
82
92
  if (buttonAction) {
83
93
  if (Array.isArray(buttonAction)) {
84
- return this._combinationsMap.has(buttonAction);
94
+ return buttonAction.some((input) => this._isInputActive(input));
85
95
  } else {
86
- return this._singleDownButtons.has(buttonAction);
96
+ return this._isInputActive(buttonAction);
87
97
  }
88
98
  } else {
89
99
  const joystickAction = this.scheme['joystick']?.[action] ?? null;
@@ -98,6 +108,22 @@ export class VirtualControls extends WithSignals(AbstractControls) {
98
108
  return false;
99
109
  }
100
110
 
111
+ /** A single button id, or a `+` separated combination requiring every input to be down. */
112
+ private _isInputActive(input: string): boolean {
113
+ if (input.includes('+')) {
114
+ return input.split('+').every((id) => this._singleDownButtons.has(id) || this._joystick?.direction === id);
115
+ }
116
+ return this._singleDownButtons.has(input);
117
+ }
118
+
119
+ private _warnMissingAction(key: string) {
120
+ if (this._warnedMissingActions.has(key)) {
121
+ return;
122
+ }
123
+ this._warnedMissingActions.add(key);
124
+ Logger.warn(`VirtualControls: scheme references unknown action "${key}"`);
125
+ }
126
+
101
127
  private _sortActions() {
102
128
  const actions = this.app.actionsPlugin.getActions();
103
129
  this._combinations = [];
@@ -110,6 +136,10 @@ export class VirtualControls extends WithSignals(AbstractControls) {
110
136
  buttons.forEach((key) => {
111
137
  const item = this._buttonDownMap[key];
112
138
  const action = actions[key];
139
+ if (!action) {
140
+ this._warnMissingAction(key);
141
+ return;
142
+ }
113
143
  if (
114
144
  action.context !== '*' &&
115
145
  action.context !== this.app.actionContext &&
@@ -141,6 +171,10 @@ export class VirtualControls extends WithSignals(AbstractControls) {
141
171
  buttons.forEach((key) => {
142
172
  const item = this._buttonUpMap[key];
143
173
  const action = actions[key];
174
+ if (!action) {
175
+ this._warnMissingAction(key);
176
+ return;
177
+ }
144
178
  if (
145
179
  action.context !== '*' &&
146
180
  action.context !== this.app.actionContext &&
@@ -1,6 +1,6 @@
1
1
  import { extensions } from 'pixi.js';
2
2
  import { Plugin } from '../Plugin';
3
- import { Spine, spineLoaderExtension, SpinePipe, spineTextureAtlasLoader } from './pixi-spine';
3
+ import { Spine, spineLoaderExtension, spineTextureAtlasLoader } from './pixi-spine';
4
4
 
5
5
  export class SpinePlugin extends Plugin {
6
6
  public readonly id = 'SpinePlugin';
@@ -8,7 +8,7 @@ export class SpinePlugin extends Plugin {
8
8
  public async initialize() {
9
9
  extensions.add(spineTextureAtlasLoader);
10
10
  extensions.add(spineLoaderExtension);
11
- extensions.add(SpinePipe);
11
+ // SpinePipe and DarkTintBatcher register themselves at module scope in ./pixi-spine
12
12
  (window as any).Spine = Spine;
13
13
  }
14
14
  }
@@ -143,4 +143,19 @@ describe('Store', () => {
143
143
  expect(saveA).toHaveBeenCalledWith('k', 1);
144
144
  expect(saveB).toHaveBeenCalledWith('k', 1);
145
145
  });
146
+
147
+ it('save with "*" anywhere in an adapter array fans out to every storage-capable plugin', async () => {
148
+ const saveA = vi.fn(async () => 'a');
149
+ const saveB = vi.fn(async () => 'b');
150
+ const store = new Store().initialize(
151
+ makeApp([
152
+ { id: 'a', save: saveA, load: async () => undefined },
153
+ { id: 'b', save: saveB, load: async () => undefined },
154
+ { id: 'c' }, // not storage-capable → must be skipped
155
+ ]),
156
+ );
157
+ await store.save(['a', '*'] as never, 'k', 1);
158
+ expect(saveA).toHaveBeenCalledWith('k', 1);
159
+ expect(saveB).toHaveBeenCalledWith('k', 1);
160
+ });
146
161
  });
@@ -126,7 +126,10 @@ export class Store implements IStore {
126
126
  keys = adapterId as string[] | Partial<AdapterSaveConfig>[];
127
127
  }
128
128
 
129
- if ((keys[0] as string) === '*' || (keys[0] as Partial<AdapterSaveConfig>)?.adapterId === '*') {
129
+ const hasStarKey = keys.some(
130
+ (k) => (k as string) === '*' || (k as Partial<AdapterSaveConfig>)?.adapterId === '*',
131
+ );
132
+ if (hasStarKey) {
130
133
  keys = this.allAdapterIds();
131
134
  }
132
135
 
@@ -0,0 +1,248 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Button transitively imports Application → Pixi display graph. Stub it — these
4
+ // tests drive the press-state machine directly and never touch the app singleton.
5
+ vi.mock('../core/Application', () => ({
6
+ Application: { getInstance: () => ({}) },
7
+ }));
8
+
9
+ import { Button } from './Button';
10
+ import { Signal } from '../signals';
11
+
12
+ /**
13
+ * Builds a Button instance without running the real constructor (which drags in
14
+ * Factory/Interactive/Focusable mixin construction and a real Pixi Sprite). Only
15
+ * the fields the handlers under test actually touch are hand-initialized.
16
+ */
17
+ function makeButton() {
18
+ const button = Object.create(Button.prototype) as Button;
19
+
20
+ // Pixi's Container (via eventemitter3) normally initializes these in its own
21
+ // constructor, which this hand-rolled instance never runs; `on`/`off` throw
22
+ // without them.
23
+ (button as any)._events = Object.create(null);
24
+ (button as any)._eventsCount = 0;
25
+
26
+ (button as any).config = {
27
+ id: 'button',
28
+ textures: { default: '' },
29
+ sheet: undefined,
30
+ enabled: true,
31
+ cursor: 'default',
32
+ disabledCursor: 'not-allowed',
33
+ };
34
+
35
+ (button as any)._enabled = true;
36
+ button.isDown = false;
37
+ button.isOver = false;
38
+ (button as any)._pointerId = undefined;
39
+ (button as any).isKeyDown = false;
40
+ (button as any).cursor = 'default';
41
+ (button as any).focusEnabled = true;
42
+
43
+ (button as any).view = { texture: undefined };
44
+ (button as any).make = { texture: vi.fn(() => 'texture') };
45
+ (button as any)._isDownCallbacks = new Map();
46
+ (button as any)._isDownListenerAdded = false;
47
+
48
+ button.onDown = new Signal();
49
+ button.onUp = new Signal();
50
+ button.onUpOutside = new Signal();
51
+ button.onOut = new Signal();
52
+ button.onOver = new Signal();
53
+ button.onClick = new Signal();
54
+ button.onEnabled = new Signal();
55
+ button.onDisabled = new Signal();
56
+ button.onKeyboardEvent = new Signal();
57
+ button.onDestroy = new Signal();
58
+
59
+ return button;
60
+ }
61
+
62
+ describe('Button press-state machine', () => {
63
+ it('recovers from a press that is disabled mid-press', () => {
64
+ const button = makeButton();
65
+ const onDown = vi.fn();
66
+ button.onDown.connect(onDown);
67
+
68
+ (button as any).handlePointerDown({ pointerId: 1 });
69
+ expect(button.isDown).toBe(true);
70
+
71
+ button.enabled = false;
72
+ expect(button.isDown).toBe(false);
73
+ expect((button as any)._pointerId).toBeUndefined();
74
+
75
+ button.enabled = true;
76
+ (button as any).handlePointerDown({ pointerId: 2 });
77
+
78
+ expect(onDown).toHaveBeenCalledTimes(2);
79
+ expect(button.isDown).toBe(true);
80
+ });
81
+
82
+ it('clears press state on pointerupoutside while disabled and does not emit onUpOutside', () => {
83
+ const button = makeButton();
84
+ const onUpOutside = vi.fn();
85
+ button.onUpOutside.connect(onUpOutside);
86
+
87
+ // Arm a stale press, then disable without going through the enabled setter,
88
+ // to exercise the handler's own cleanup path directly.
89
+ button.isDown = true;
90
+ (button as any)._pointerId = 1;
91
+ (button as any)._enabled = false;
92
+
93
+ (button as any).handlePointerUpOutside({ pointerId: 1 });
94
+
95
+ expect(button.isDown).toBe(false);
96
+ expect((button as any)._pointerId).toBeUndefined();
97
+ expect(onUpOutside).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('removes the window pointerup listener on destroy', () => {
101
+ const button = makeButton();
102
+ (button as any).handlePointerDown({ pointerId: 1 });
103
+
104
+ // Stub out the real mixin chain's destroy() (Focusable → Interactive →
105
+ // WithSignals → Factory → real Pixi Container.destroy) so this test isolates
106
+ // Button.destroy()'s own added cleanup without needing a fully-constructed
107
+ // Container underneath.
108
+ const superDestroy = vi
109
+ .spyOn(Object.getPrototypeOf(Button.prototype), 'destroy')
110
+ .mockImplementation(() => {});
111
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
112
+
113
+ button.destroy();
114
+
115
+ expect(removeSpy).toHaveBeenCalledWith('pointerup', (button as any).handlePointerUpOutside);
116
+ expect(superDestroy).toHaveBeenCalled();
117
+ expect(button.isDown).toBe(false);
118
+ expect((button as any)._pointerId).toBeUndefined();
119
+
120
+ removeSpy.mockRestore();
121
+ superDestroy.mockRestore();
122
+ });
123
+
124
+ it('does not click after a cancelled press (disabled then re-enabled)', () => {
125
+ const button = makeButton();
126
+ const onClick = vi.fn();
127
+ button.onClick.connect(onClick);
128
+
129
+ (button as any).handlePointerDown({ pointerId: 1 });
130
+ button.enabled = false;
131
+ button.enabled = true;
132
+
133
+ // A real pointer-driven click event carries the same pointerId as the press.
134
+ (button as any).handleClick({ pointerId: 1 });
135
+
136
+ expect(onClick).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('clicks normally after a press', () => {
140
+ const button = makeButton();
141
+ const onClick = vi.fn();
142
+ button.onClick.connect(onClick);
143
+
144
+ (button as any).handlePointerDown({ pointerId: 1 });
145
+ (button as any).handleClick({ pointerId: 1 });
146
+
147
+ expect(onClick).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('dispatches a click action with no data without throwing, attaching the button to the payload', () => {
151
+ const button = makeButton();
152
+ (button as any).config.actions = { click: { id: 'foo' } };
153
+ const action = vi.fn();
154
+ Object.defineProperty(button, 'app', { value: { action }, configurable: true });
155
+
156
+ (button as any).handlePointerDown({ pointerId: 1 });
157
+ expect(() => (button as any).handleClick({ pointerId: 1 })).not.toThrow();
158
+
159
+ expect(action).toHaveBeenCalledWith('foo', { button });
160
+ });
161
+
162
+ it('still clicks for a synthetic keyboard activation (no pointerId)', () => {
163
+ const button = makeButton();
164
+ const onClick = vi.fn();
165
+ button.onClick.connect(onClick);
166
+
167
+ // Mimics FocusManagerPlugin's synthetic pointerdown on Enter/Space keydown.
168
+ (button as any).handlePointerDown({ type: 'pointerdown' });
169
+ (button as any).handleClick();
170
+
171
+ expect(onClick).toHaveBeenCalledTimes(1);
172
+ });
173
+
174
+ it('fires onClick exactly once for a single accessibility activation delivered as click+tap', () => {
175
+ const button = makeButton();
176
+ const onClick = vi.fn();
177
+ button.onClick.connect(onClick);
178
+
179
+ // AccessibilitySystem dispatches ONE shared FederatedEvent instance as both
180
+ // 'click' and 'tap' — Button connects both to handleClick.
181
+ const a11yEvent = { type: 'click' };
182
+ (button as any).handleClick(a11yEvent);
183
+ (button as any).handleClick(a11yEvent);
184
+
185
+ expect(onClick).toHaveBeenCalledTimes(1);
186
+ });
187
+
188
+ it('suppresses a cancelled pointer-driven click even without a prior press in this test', () => {
189
+ const button = makeButton();
190
+ const onClick = vi.fn();
191
+ button.onClick.connect(onClick);
192
+
193
+ // isDown is false (no press) — a click event carrying a pointerId must be treated
194
+ // as a cancelled press and suppressed.
195
+ (button as any).handleClick({ pointerId: 5 });
196
+
197
+ expect(onClick).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('fires onClick for each of two distinct accessibility activations', () => {
201
+ const button = makeButton();
202
+ const onClick = vi.fn();
203
+ button.onClick.connect(onClick);
204
+
205
+ (button as any).handleClick({ type: 'click' });
206
+ (button as any).handleClick({ type: 'click' });
207
+
208
+ expect(onClick).toHaveBeenCalledTimes(2);
209
+ });
210
+
211
+ it('toggles the ticker listener on for the first isDown callback and off when the last is removed', () => {
212
+ const button = makeButton();
213
+ const add = vi.fn();
214
+ const remove = vi.fn();
215
+ Object.defineProperty(button, 'app', { value: { ticker: { add, remove } }, configurable: true });
216
+
217
+ button.addIsDownCallback('a', () => {});
218
+ button.addIsDownCallback('b', () => {});
219
+
220
+ expect(add).toHaveBeenCalledTimes(1);
221
+ expect(remove).not.toHaveBeenCalled();
222
+
223
+ button.removeIsDownCallback('a');
224
+ expect(remove).not.toHaveBeenCalled();
225
+
226
+ button.removeIsDownCallback('b');
227
+ expect(remove).toHaveBeenCalledTimes(1);
228
+ });
229
+
230
+ it('removes the ticker listener on destroy when one is active', () => {
231
+ const button = makeButton();
232
+ const add = vi.fn();
233
+ const remove = vi.fn();
234
+ Object.defineProperty(button, 'app', { value: { ticker: { add, remove } }, configurable: true });
235
+
236
+ button.addIsDownCallback('a', () => {});
237
+
238
+ const superDestroy = vi
239
+ .spyOn(Object.getPrototypeOf(Button.prototype), 'destroy')
240
+ .mockImplementation(() => {});
241
+
242
+ button.destroy();
243
+
244
+ expect(remove).toHaveBeenCalledTimes(1);
245
+
246
+ superDestroy.mockRestore();
247
+ });
248
+ });
package/src/ui/Button.ts CHANGED
@@ -121,6 +121,7 @@ export class Button extends _Button implements IButton {
121
121
  private _isDownListenerAdded: boolean = false;
122
122
  private _pointerId?: number;
123
123
  private _textLabel?: Text | HTMLText | BitmapText;
124
+ private _lastClickEvent?: FederatedEvent;
124
125
 
125
126
  /**
126
127
  * The text label of the button.
@@ -190,6 +191,8 @@ export class Button extends _Button implements IButton {
190
191
  this.onInteraction('tap').connect(this.handleClick, -1),
191
192
  this.onInteraction('pointerdown').connect(this.handlePointerDown, -1),
192
193
  );
194
+
195
+ this.on('removed', this._resetPressState);
193
196
  }
194
197
 
195
198
  // enabled state
@@ -213,6 +216,7 @@ export class Button extends _Button implements IButton {
213
216
  });
214
217
  this.onEnabled.emit();
215
218
  } else {
219
+ this._resetPressState();
216
220
  this.view.texture = this.make.texture({
217
221
  asset: this.config.textures.disabled || this.config.textures.default,
218
222
  sheet: this.config.sheet ?? undefined,
@@ -221,6 +225,18 @@ export class Button extends _Button implements IButton {
221
225
  }
222
226
  }
223
227
 
228
+ /**
229
+ * Clears any in-progress press: removes the window-level release listener and
230
+ * resets press tracking. Called when the button is disabled, removed, or
231
+ * destroyed mid-press so a stale pointerId can't block future presses.
232
+ */
233
+ private _resetPressState() {
234
+ window.removeEventListener('pointerup', this.handlePointerUpOutside);
235
+ this.off('pointerupoutside', this.handlePointerUpOutside);
236
+ this.isDown = false;
237
+ this._pointerId = undefined;
238
+ }
239
+
224
240
  public addLabel<T extends Text | HTMLText | BitmapText>(
225
241
  config: (Partial<TextProps | HTMLTextProps> & { type?: 'text' | 'html' | 'bitmap' }) | T,
226
242
  ): T {
@@ -264,6 +280,11 @@ export class Button extends _Button implements IButton {
264
280
 
265
281
  destroy(options?: DestroyOptions) {
266
282
  this.onDestroy.emit();
283
+ this._resetPressState();
284
+ if (this._isDownListenerAdded) {
285
+ this.app.ticker.remove(this._handleIsDownCallbacks);
286
+ this._isDownListenerAdded = false;
287
+ }
267
288
  super.destroy(options);
268
289
  }
269
290
 
@@ -290,6 +311,7 @@ export class Button extends _Button implements IButton {
290
311
 
291
312
  removeIsDownCallback(callbackId: string) {
292
313
  this._isDownCallbacks.delete(callbackId);
314
+ this._checkIsDownCallbacks();
293
315
  }
294
316
 
295
317
  setTexture(textureId: ButtonTextureId, texture: TextureAsset) {
@@ -395,10 +417,20 @@ export class Button extends _Button implements IButton {
395
417
  this._pointerId = undefined;
396
418
  }
397
419
 
398
- protected handleClick() {
420
+ protected handleClick(e?: FederatedEvent) {
399
421
  if (!this._enabled) {
400
422
  return;
401
423
  }
424
+ const pointerId = (e as FederatedPointerEvent | undefined)?.pointerId;
425
+ if (pointerId !== undefined && !this.isDown) {
426
+ // a pointer press that was cancelled (e.g. disabled mid-press) must not click
427
+ return;
428
+ }
429
+ if (e !== undefined && e === this._lastClickEvent) {
430
+ // accessibility dispatches one event instance as both 'click' and 'tap'
431
+ return;
432
+ }
433
+ this._lastClickEvent = e;
402
434
  this.isDown = false;
403
435
  this.onClick.emit();
404
436
  if (this.config.sounds?.click) {
@@ -413,23 +445,23 @@ export class Button extends _Button implements IButton {
413
445
  * Handles the pointer up event.
414
446
  */
415
447
  protected handlePointerUpOutside(e: PointerEvent | FederatedPointerEvent) {
416
- if (!this._enabled || e.pointerId !== this._pointerId) {
448
+ if (e.pointerId !== this._pointerId) {
417
449
  return;
418
450
  }
419
- window.removeEventListener('pointerup', this.handlePointerUpOutside);
420
- this.off('pointerupoutside', this.handlePointerUpOutside);
421
- this.view.texture = this.make.texture({ asset: this.config.textures.default, sheet: this.config.sheet });
422
- this.isDown = false;
451
+ const wasEnabled = this._enabled;
452
+ this._resetPressState();
423
453
  this.isOver = false;
424
- this.onUpOutside.emit();
425
-
426
- this._pointerId = undefined;
454
+ if (wasEnabled) {
455
+ this.view.texture = this.make.texture({ asset: this.config.textures.default, sheet: this.config.sheet });
456
+ this.onUpOutside.emit();
457
+ }
427
458
  }
428
459
 
429
460
  private _doAction(action: ButtonActionOrCallback) {
430
461
  if (typeof action === 'function') {
431
462
  void action();
432
463
  } else {
464
+ action.data ??= {};
433
465
  if (!action.data.button) {
434
466
  action.data.button = this;
435
467
  }
@@ -442,7 +474,7 @@ export class Button extends _Button implements IButton {
442
474
  if (!this._isDownListenerAdded && this._isDownCallbacks.size > 0) {
443
475
  this._isDownListenerAdded = true;
444
476
  this.app.ticker.add(this._handleIsDownCallbacks);
445
- } else {
477
+ } else if (this._isDownListenerAdded && this._isDownCallbacks.size === 0) {
446
478
  this.app.ticker.remove(this._handleIsDownCallbacks);
447
479
  this._isDownListenerAdded = false;
448
480
  }