@caperjs/core 0.2.2 → 0.2.3

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 (137) hide show
  1. package/build/index.mjs +2 -0
  2. package/build/plugins/viewport.mjs +52 -0
  3. package/lib/{CaptionsPlugin-DeGyPecB.js → CaptionsPlugin-WPAcfAkx.js} +13 -12
  4. package/lib/CaptionsPlugin-WPAcfAkx.js.map +1 -0
  5. package/lib/{DataAdapter-DeI2kIxC.js → DataAdapter-DS-twowe.js} +11 -11
  6. package/lib/{DataAdapter-DeI2kIxC.js.map → DataAdapter-DS-twowe.js.map} +1 -1
  7. package/lib/{DebugRenderer-wjV0tMwL.js → DebugRenderer-D1ryrRYx.js} +2 -2
  8. package/lib/{DebugRenderer-wjV0tMwL.js.map → DebugRenderer-D1ryrRYx.js.map} +1 -1
  9. package/lib/{DevToolsPlugin-D4iWQwkO.js → DevToolsPlugin-DH7zX7HU.js} +2 -2
  10. package/lib/{DevToolsPlugin-D4iWQwkO.js.map → DevToolsPlugin-DH7zX7HU.js.map} +1 -1
  11. package/lib/{GSAPPlugin-DQTyIwcC.js → GSAPPlugin-BBtRjUqe.js} +2 -2
  12. package/lib/{GSAPPlugin-DQTyIwcC.js.map → GSAPPlugin-BBtRjUqe.js.map} +1 -1
  13. package/lib/{LayoutPlugin-vR_h-BaG.js → LayoutPlugin-Co6vcoV4.js} +4 -4
  14. package/lib/{LayoutPlugin-vR_h-BaG.js.map → LayoutPlugin-Co6vcoV4.js.map} +1 -1
  15. package/lib/{SpinePlugin-DJ-PpxFj.js → SpinePlugin-uN641Hmk.js} +2 -2
  16. package/lib/{SpinePlugin-DJ-PpxFj.js.map → SpinePlugin-uN641Hmk.js.map} +1 -1
  17. package/lib/{StatsPlugin-BYNnKYOm.js → StatsPlugin-vZQRm6wh.js} +4 -4
  18. package/lib/{StatsPlugin-BYNnKYOm.js.map → StatsPlugin-vZQRm6wh.js.map} +1 -1
  19. package/lib/{VoiceOverPlugin-BLSbq3DU.js → VoiceOverPlugin-KlZdGj1G.js} +32 -32
  20. package/lib/{VoiceOverPlugin-BLSbq3DU.js.map → VoiceOverPlugin-KlZdGj1G.js.map} +1 -1
  21. package/lib/caper.mjs +2107 -353
  22. package/lib/caper.mjs.map +1 -1
  23. package/lib/core/Application.d.ts +7 -1
  24. package/lib/core/Application.d.ts.map +1 -1
  25. package/lib/core/interfaces/IApplicationOptions.d.ts +1 -1
  26. package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
  27. package/lib/core/interfaces/ICoreFunctions.d.ts +1 -0
  28. package/lib/core/interfaces/ICoreFunctions.d.ts.map +1 -1
  29. package/lib/display/AnimatedSprite.d.ts +1 -1
  30. package/lib/display/AnimatedSprite.d.ts.map +1 -1
  31. package/lib/display/Container.d.ts.map +1 -1
  32. package/lib/display/SpineAnimation.d.ts +1 -1
  33. package/lib/display/SpineAnimation.d.ts.map +1 -1
  34. package/lib/importOrder.containerFirst.test.d.ts +2 -0
  35. package/lib/importOrder.containerFirst.test.d.ts.map +1 -0
  36. package/lib/importOrder.displayFirst.test.d.ts +2 -0
  37. package/lib/importOrder.displayFirst.test.d.ts.map +1 -0
  38. package/lib/importOrder.mixinsFirst.test.d.ts +2 -0
  39. package/lib/importOrder.mixinsFirst.test.d.ts.map +1 -0
  40. package/lib/importOrder.sceneFirst.test.d.ts +2 -0
  41. package/lib/importOrder.sceneFirst.test.d.ts.map +1 -0
  42. package/lib/importOrder.toastFirst.test.d.ts +2 -0
  43. package/lib/importOrder.toastFirst.test.d.ts.map +1 -0
  44. package/lib/importOrder.uiFirst.test.d.ts +2 -0
  45. package/lib/importOrder.uiFirst.test.d.ts.map +1 -0
  46. package/lib/mixins/factory/Factory.d.ts +1 -1
  47. package/lib/mixins/factory/Factory.d.ts.map +1 -1
  48. package/lib/mixins/factory/Factory.test.d.ts +1 -0
  49. package/lib/mixins/factory/Factory.test.d.ts.map +1 -0
  50. package/lib/mixins/factory/const.d.ts +9 -2
  51. package/lib/mixins/factory/const.d.ts.map +1 -1
  52. package/lib/mixins/factory/defaults.d.ts +5 -0
  53. package/lib/mixins/factory/defaults.d.ts.map +1 -0
  54. package/lib/mixins/factory/methods.d.ts +1 -1
  55. package/lib/mixins/factory/methods.d.ts.map +1 -1
  56. package/lib/mixins/factory/props.d.ts +4 -2
  57. package/lib/mixins/factory/props.d.ts.map +1 -1
  58. package/lib/plugins/PopupManagerPlugin.d.ts +1 -1
  59. package/lib/plugins/PopupManagerPlugin.d.ts.map +1 -1
  60. package/lib/plugins/ResizerPlugin.d.ts +29 -0
  61. package/lib/plugins/ResizerPlugin.d.ts.map +1 -1
  62. package/lib/plugins/ResizerPlugin.test.d.ts +2 -0
  63. package/lib/plugins/ResizerPlugin.test.d.ts.map +1 -0
  64. package/lib/plugins/WebEventsPlugin.d.ts +5 -0
  65. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  66. package/lib/plugins/WebEventsPlugin.test.d.ts +2 -0
  67. package/lib/plugins/WebEventsPlugin.test.d.ts.map +1 -0
  68. package/lib/plugins/captions/CaptionsRenderer.d.ts +1 -1
  69. package/lib/plugins/captions/CaptionsRenderer.d.ts.map +1 -1
  70. package/lib/plugins/i18nPlugin.d.ts +29 -3
  71. package/lib/plugins/i18nPlugin.d.ts.map +1 -1
  72. package/lib/plugins/i18nPlugin.test.d.ts +2 -0
  73. package/lib/plugins/i18nPlugin.test.d.ts.map +1 -0
  74. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  75. package/lib/plugins/spine/pixi-spine/require-shim.d.ts +0 -1
  76. package/lib/plugins/spine/pixi-spine/require-shim.d.ts.map +1 -1
  77. package/lib/{registries-aN0y0Xha.js → registries-BLrxALZ0.js} +1124 -2801
  78. package/lib/registries-BLrxALZ0.js.map +1 -0
  79. package/lib/ui/Button.d.ts +1 -1
  80. package/lib/ui/Button.d.ts.map +1 -1
  81. package/lib/ui/FlexContainer.d.ts +1 -1
  82. package/lib/ui/FlexContainer.d.ts.map +1 -1
  83. package/lib/ui/Input.d.ts +2 -2
  84. package/lib/ui/Input.d.ts.map +1 -1
  85. package/lib/ui/Joystick.d.ts +1 -1
  86. package/lib/ui/Joystick.d.ts.map +1 -1
  87. package/lib/ui/Toast.d.ts +1 -1
  88. package/lib/ui/Toast.d.ts.map +1 -1
  89. package/lib/ui/UICanvas.d.ts +11 -1
  90. package/lib/ui/UICanvas.d.ts.map +1 -1
  91. package/lib/ui/UICanvas.test.d.ts +2 -0
  92. package/lib/ui/UICanvas.test.d.ts.map +1 -0
  93. package/lib/utils/types.d.ts +1 -2
  94. package/lib/utils/types.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/src/core/Application.ts +15 -2
  97. package/src/core/interfaces/IApplicationOptions.ts +1 -1
  98. package/src/core/interfaces/ICoreFunctions.ts +1 -0
  99. package/src/display/AnimatedSprite.ts +2 -1
  100. package/src/display/Container.ts +3 -1
  101. package/src/display/SpineAnimation.ts +3 -2
  102. package/src/importOrder.containerFirst.test.ts +32 -0
  103. package/src/importOrder.displayFirst.test.ts +19 -0
  104. package/src/importOrder.mixinsFirst.test.ts +18 -0
  105. package/src/importOrder.sceneFirst.test.ts +21 -0
  106. package/src/importOrder.toastFirst.test.ts +20 -0
  107. package/src/importOrder.uiFirst.test.ts +24 -0
  108. package/src/mixins/factory/Factory.test.ts +22 -0
  109. package/src/mixins/factory/Factory.ts +9 -2
  110. package/src/mixins/factory/const.ts +18 -11
  111. package/src/mixins/factory/defaults.ts +28 -0
  112. package/src/mixins/factory/methods.ts +1 -1
  113. package/src/mixins/factory/props.ts +5 -3
  114. package/src/plugins/LookupPlugin.ts +1 -1
  115. package/src/plugins/PopupManagerPlugin.ts +1 -1
  116. package/src/plugins/ResizerPlugin.test.ts +84 -0
  117. package/src/plugins/ResizerPlugin.ts +59 -1
  118. package/src/plugins/WebEventsPlugin.test.ts +75 -0
  119. package/src/plugins/WebEventsPlugin.ts +15 -0
  120. package/src/plugins/captions/CaptionsRenderer.ts +1 -1
  121. package/src/plugins/i18nPlugin.test.ts +124 -0
  122. package/src/plugins/i18nPlugin.ts +57 -18
  123. package/src/plugins/input/keyboard/KeyboardContols.ts +1 -1
  124. package/src/plugins/input/touch/VirtualControls.ts +2 -2
  125. package/src/plugins/spine/pixi-spine/require-shim.ts +3 -1
  126. package/src/ui/Button.ts +5 -1
  127. package/src/ui/FlexContainer.ts +3 -2
  128. package/src/ui/Input.ts +5 -2
  129. package/src/ui/Joystick.ts +1 -1
  130. package/src/ui/Toast.ts +2 -1
  131. package/src/ui/Toaster.ts +1 -1
  132. package/src/ui/UICanvas.test.ts +35 -0
  133. package/src/ui/UICanvas.ts +46 -8
  134. package/src/utils/types.ts +1 -1
  135. package/src/version.ts +1 -1
  136. package/lib/CaptionsPlugin-DeGyPecB.js.map +0 -1
  137. package/lib/registries-aN0y0Xha.js.map +0 -1
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Registration slot for the factory method table.
3
+ *
4
+ * This module deliberately has ZERO imports. `mixins/factory/const` builds the
5
+ * table (and to do so imports every ui/display class), while `Factory()` — which
6
+ * every one of those classes extends — needs the table at construct time. A
7
+ * direct import either way closes the cycle and hands out an undefined binding
8
+ * to whichever module is mid-evaluation. Routing through this import-free slot
9
+ * breaks the edge: `const.ts` pushes the table in when it evaluates, `Factory`
10
+ * pulls it out lazily, inside the constructor.
11
+ */
12
+ let defaults: Record<string, any> | null = null;
13
+
14
+ /** Called by `mixins/factory/const` as soon as the table is defined. */
15
+ export function setDefaultFactoryMethods(methods: Record<string, any>): void {
16
+ defaults = methods;
17
+ }
18
+
19
+ /** Read the registered table. Throws if `const.ts` has not evaluated yet. */
20
+ export function getDefaultFactoryMethods(): Record<string, any> {
21
+ if (!defaults) {
22
+ throw new Error(
23
+ "[caper] The factory method table hasn't been initialized. Import '@caperjs/core' (or its factory module) " +
24
+ 'before deep-importing display/ui classes so the table is registered first.',
25
+ );
26
+ }
27
+ return defaults;
28
+ }
@@ -1,4 +1,4 @@
1
- import { defaultFactoryMethods } from './index';
1
+ import type { defaultFactoryMethods } from './const';
2
2
 
3
3
  export function createFactoryMethods<T extends typeof defaultFactoryMethods = typeof defaultFactoryMethods>(
4
4
  methods: Partial<T>,
@@ -1,8 +1,10 @@
1
1
  import type { LayoutOptions } from '@pixi/layout';
2
2
  import { GraphicsContext, HTMLTextStyleOptions, TextStyleOptions } from 'pixi.js';
3
- import { ContainerConfig } from '../../display';
4
- import { ParticleContainerConfig } from '../../display/ParticleContainer';
5
- import { ButtonConfig, FlexContainerConfig, UICanvasProps } from '../../ui';
3
+ import type { ContainerConfig } from '../../display/Container';
4
+ import type { ParticleContainerConfig } from '../../display/ParticleContainer';
5
+ import type { ButtonConfig } from '../../ui/Button';
6
+ import type { FlexContainerConfig } from '../../ui/FlexContainer';
7
+ import type { UICanvasProps } from '../../ui/UICanvas';
6
8
  import type {
7
9
  BitmapFontFamilyAsset,
8
10
  FontFamilyAsset,
@@ -1,6 +1,6 @@
1
1
  import { Container as PIXIContainer } from 'pixi.js';
2
2
  import { IPlugin, Plugin } from '.';
3
- import { Container } from '../display';
3
+ import { Container } from '../display/Container';
4
4
 
5
5
  /**
6
6
  * Interface for the LookupPlugin providing fast container lookup functionality.
@@ -1,4 +1,4 @@
1
- import { Container } from '../display';
1
+ import { Container } from '../display/Container';
2
2
  import { Signal } from '../signals';
3
3
  import type { IPopup, PopupConfig, PopupConstructor } from '../ui';
4
4
  import { bindAllMethods, getLastMapEntry } from '../utils';
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // ResizerPlugin transitively imports Application → Pixi display graph. Stub it.
4
+ vi.mock('../core', () => ({
5
+ coreFunctionRegistry: {},
6
+ coreSignalRegistry: {},
7
+ }));
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ app: {
11
+ config: { resizeToContainer: false } as { resizeToContainer: boolean },
12
+ renderer: {
13
+ canvas: { style: {} as Record<string, string>, parentElement: null as unknown },
14
+ resize: (_w: number, _h: number) => {},
15
+ },
16
+ },
17
+ }));
18
+
19
+ vi.mock('../core/Application', () => ({
20
+ Application: { getInstance: () => mocks.app },
21
+ }));
22
+
23
+ import { ResizerPlugin } from './ResizerPlugin';
24
+
25
+ /** Shrinks the container below minWidth/minHeight so `_resize()` computes scale > 1. */
26
+ function useContainer(width: number, height: number) {
27
+ mocks.app.config.resizeToContainer = true;
28
+ mocks.app.renderer.canvas.parentElement = {
29
+ getBoundingClientRect: () => ({ width, height }),
30
+ };
31
+ }
32
+
33
+ describe('ResizerPlugin safe area', () => {
34
+ let plugin: ResizerPlugin;
35
+
36
+ beforeEach(async () => {
37
+ mocks.app.config.resizeToContainer = false;
38
+ mocks.app.renderer.canvas = { style: {}, parentElement: null };
39
+ plugin = new ResizerPlugin();
40
+ });
41
+
42
+ it('reports zero insets before the first resize', () => {
43
+ expect(plugin.safeArea).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
44
+ });
45
+
46
+ it('scales measured insets into logical render units', async () => {
47
+ await plugin.initialize({ minWidth: 1000, minHeight: 1000 });
48
+ useContainer(500, 500);
49
+ plugin._measureSafeAreaCssPx = () => ({ top: 44, right: 0, bottom: 34, left: 12 });
50
+
51
+ plugin._resize();
52
+
53
+ expect(plugin.scale).toBe(2);
54
+ expect(plugin.safeArea).toEqual({ top: 88, right: 0, bottom: 68, left: 24 });
55
+ });
56
+
57
+ it('passes measured insets through unscaled at scale 1', async () => {
58
+ await plugin.initialize({ minWidth: 500, minHeight: 500 });
59
+ plugin._measureSafeAreaCssPx = () => ({ top: 44, right: 0, bottom: 34, left: 12 });
60
+
61
+ plugin._resize();
62
+
63
+ expect(plugin.scale).toBe(1);
64
+ expect(plugin.safeArea).toEqual({ top: 44, right: 0, bottom: 34, left: 12 });
65
+ });
66
+
67
+ it('reports zero insets when useSafeArea is off', async () => {
68
+ await plugin.initialize({ minWidth: 500, minHeight: 500, useSafeArea: false });
69
+ plugin._measureSafeAreaCssPx = () => ({ top: 44, right: 0, bottom: 34, left: 12 });
70
+
71
+ plugin._resize();
72
+
73
+ expect(plugin.safeArea).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
74
+ });
75
+
76
+ it('measures 0 when the browser does not support env()', async () => {
77
+ await plugin.initialize({ minWidth: 500, minHeight: 500 });
78
+
79
+ plugin._resize();
80
+
81
+ expect(plugin.safeArea).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
82
+ plugin.destroy();
83
+ });
84
+ });
@@ -1,6 +1,6 @@
1
1
  import { Graphics } from 'pixi.js';
2
2
 
3
- import { Container } from '../display';
3
+ import { Container } from '../display/Container';
4
4
  import type { Size } from '../utils';
5
5
  import type { IPlugin } from './Plugin';
6
6
  import { Plugin } from './Plugin';
@@ -11,6 +11,8 @@ import { Plugin } from './Plugin';
11
11
  export interface IResizerPlugin extends IPlugin {
12
12
  readonly size: Size;
13
13
  readonly scale: number;
14
+ /** Device safe-area insets, in logical render units (already scaled like `size`). */
15
+ readonly safeArea: { top: number; right: number; bottom: number; left: number };
14
16
  resize(): Promise<Size>;
15
17
  }
16
18
 
@@ -30,6 +32,8 @@ export type ResizerPluginOptions = {
30
32
  center: boolean;
31
33
  /** Whether to draw debug information for visualizing canvas bounds */
32
34
  debug: boolean;
35
+ /** Whether to measure the device safe-area insets (notch / status bar / gesture bar) */
36
+ useSafeArea: boolean;
33
37
  };
34
38
 
35
39
  /**
@@ -42,6 +46,7 @@ const defaultOptions: ResizerPluginOptions = {
42
46
  letterbox: false,
43
47
  center: false,
44
48
  debug: false,
49
+ useSafeArea: true,
45
50
  };
46
51
 
47
52
  export class ResizerPlugin extends Plugin<ResizerPluginOptions> implements IResizerPlugin {
@@ -51,6 +56,8 @@ export class ResizerPlugin extends Plugin<ResizerPluginOptions> implements IResi
51
56
  private _size: Size;
52
57
  private _scale: number;
53
58
  private _resizeId: number | null;
59
+ private _safeArea = { top: 0, right: 0, bottom: 0, left: 0 };
60
+ private _safeAreaProbe: HTMLDivElement | null = null;
54
61
 
55
62
  get size(): Size {
56
63
  return this._size;
@@ -60,6 +67,10 @@ export class ResizerPlugin extends Plugin<ResizerPluginOptions> implements IResi
60
67
  return this._scale;
61
68
  }
62
69
 
70
+ get safeArea(): { top: number; right: number; bottom: number; left: number } {
71
+ return this._safeArea;
72
+ }
73
+
63
74
  /**
64
75
  * Initializes the Resizer module.
65
76
  */
@@ -75,6 +86,39 @@ export class ResizerPlugin extends Plugin<ResizerPluginOptions> implements IResi
75
86
  this.resize();
76
87
  }
77
88
 
89
+ public destroy(): void {
90
+ if (this._safeAreaProbe) {
91
+ this._safeAreaProbe.remove();
92
+ this._safeAreaProbe = null;
93
+ }
94
+ super.destroy();
95
+ }
96
+
97
+ /**
98
+ * Measures the device safe-area insets in CSS pixels, via a hidden probe element
99
+ * padded with `env(safe-area-inset-*)`. The probe is created once and reused.
100
+ * Browsers without env() support (and happy-dom) report empty strings, which fall back to 0.
101
+ */
102
+ _measureSafeAreaCssPx(): { top: number; right: number; bottom: number; left: number } {
103
+ if (!this._safeAreaProbe) {
104
+ const probe = document.createElement('div');
105
+ probe.style.cssText =
106
+ 'position:fixed; top:0; left:0; width:0; height:0; visibility:hidden; pointer-events:none;' +
107
+ 'padding-top:env(safe-area-inset-top, 0px); padding-right:env(safe-area-inset-right, 0px);' +
108
+ 'padding-bottom:env(safe-area-inset-bottom, 0px); padding-left:env(safe-area-inset-left, 0px);';
109
+ document.body.appendChild(probe);
110
+ this._safeAreaProbe = probe;
111
+ }
112
+
113
+ const style = getComputedStyle(this._safeAreaProbe);
114
+ return {
115
+ top: parseFloat(style.paddingTop) || 0,
116
+ right: parseFloat(style.paddingRight) || 0,
117
+ bottom: parseFloat(style.paddingBottom) || 0,
118
+ left: parseFloat(style.paddingLeft) || 0,
119
+ };
120
+ }
121
+
78
122
  async resize(): Promise<Size> {
79
123
  this._cancelResize!();
80
124
  return new Promise((resolve) => {
@@ -151,6 +195,20 @@ export class ResizerPlugin extends Plugin<ResizerPluginOptions> implements IResi
151
195
 
152
196
  this._scale = scale;
153
197
 
198
+ // Safe-area insets are measured in CSS pixels; the renderer's logical size is
199
+ // CSS pixels * scale, so the insets have to be scaled the same way.
200
+ if (this._options.useSafeArea) {
201
+ const insets = this._measureSafeAreaCssPx();
202
+ this._safeArea = {
203
+ top: insets.top * scale,
204
+ right: insets.right * scale,
205
+ bottom: insets.bottom * scale,
206
+ left: insets.left * scale,
207
+ };
208
+ } else {
209
+ this._safeArea = { top: 0, right: 0, bottom: 0, left: 0 };
210
+ }
211
+
154
212
  // Update canvas style dimensions and scroll window up to avoid issues on mobile resize
155
213
  if (letterbox) {
156
214
  if (canvasWidth > canvasHeight) {
@@ -0,0 +1,75 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // WebEventsPlugin transitively imports Application → Pixi display graph. Stub it.
4
+ vi.mock('../core', () => ({
5
+ coreFunctionRegistry: {},
6
+ coreSignalRegistry: {},
7
+ }));
8
+ vi.mock('../core/Application', () => ({
9
+ Application: { getInstance: () => ({ renderer: { canvas: null } }) },
10
+ }));
11
+
12
+ import { WebEventsPlugin } from './WebEventsPlugin';
13
+
14
+ type FakeVisualViewport = EventTarget & { scale: number };
15
+
16
+ function installFakeVisualViewport(): FakeVisualViewport {
17
+ const vv = new EventTarget() as FakeVisualViewport;
18
+ vv.scale = 1;
19
+ (window as any).visualViewport = vv;
20
+ return vv;
21
+ }
22
+
23
+ describe('WebEventsPlugin visualViewport resize', () => {
24
+ let plugin: WebEventsPlugin;
25
+
26
+ beforeEach(() => {
27
+ delete (window as any).visualViewport;
28
+ plugin = new WebEventsPlugin();
29
+ });
30
+
31
+ afterEach(() => {
32
+ plugin.destroy();
33
+ delete (window as any).visualViewport;
34
+ });
35
+
36
+ it('emits onResize when the visual viewport resizes at scale 1', () => {
37
+ const vv = installFakeVisualViewport();
38
+ plugin.initialize();
39
+ const spy = vi.fn();
40
+ plugin.onResize.connect(spy);
41
+
42
+ vv.dispatchEvent(new Event('resize'));
43
+
44
+ expect(spy).toHaveBeenCalledTimes(1);
45
+ expect(spy).toHaveBeenCalledWith({ width: window.innerWidth, height: window.innerHeight });
46
+ });
47
+
48
+ it('ignores visual viewport resizes during pinch-zoom (scale !== 1)', () => {
49
+ const vv = installFakeVisualViewport();
50
+ plugin.initialize();
51
+ const spy = vi.fn();
52
+ plugin.onResize.connect(spy);
53
+
54
+ vv.scale = 2;
55
+ vv.dispatchEvent(new Event('resize'));
56
+
57
+ expect(spy).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it('stops listening after destroy', () => {
61
+ const vv = installFakeVisualViewport();
62
+ plugin.initialize();
63
+ const spy = vi.fn();
64
+ plugin.onResize.connect(spy);
65
+
66
+ plugin.destroy();
67
+ vv.dispatchEvent(new Event('resize'));
68
+
69
+ expect(spy).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('initializes without error when visualViewport is unavailable', () => {
73
+ expect(() => plugin.initialize()).not.toThrow();
74
+ });
75
+ });
@@ -50,6 +50,9 @@ export class WebEventsPlugin extends Plugin implements IWebEventsPlugin {
50
50
  window.addEventListener('resize', this._onResize);
51
51
  document.addEventListener('fullscreenchange', this._onResize);
52
52
  window.addEventListener('orientationchange', this._onOrientationChanged);
53
+ // installed PWAs (standalone/fullscreen display modes) can miss or mistime window.resize
54
+ // during launch transitions; visualViewport reports the settled size
55
+ window.visualViewport?.addEventListener('resize', this._onVisualViewportResize);
53
56
  }
54
57
 
55
58
  public destroy() {
@@ -58,6 +61,7 @@ export class WebEventsPlugin extends Plugin implements IWebEventsPlugin {
58
61
  document.removeEventListener('fullscreenchange', this._onResize);
59
62
  window.removeEventListener('pagehide', this._onPageHide, false);
60
63
  window.removeEventListener('pageshow', this._onPageShow, false);
64
+ window.visualViewport?.removeEventListener('resize', this._onVisualViewportResize);
61
65
  }
62
66
 
63
67
  protected getCoreSignals(): string[] {
@@ -85,6 +89,17 @@ export class WebEventsPlugin extends Plugin implements IWebEventsPlugin {
85
89
  this.onResize.emit({ width: screenWidth, height: screenHeight });
86
90
  }
87
91
 
92
+ /**
93
+ * Called when the visual viewport resizes. Ignores pinch-zoom (scale !== 1),
94
+ * where the layout size hasn't actually changed.
95
+ */
96
+ private _onVisualViewportResize(): void {
97
+ if ((window.visualViewport?.scale ?? 1) !== 1) {
98
+ return;
99
+ }
100
+ this._onResize();
101
+ }
102
+
88
103
  /**
89
104
  * Called when the page is hidden.
90
105
  * Some browsers (like Safari) don't support the `visibilitychange` event, so we also listen for `pagehide`.
@@ -1,6 +1,6 @@
1
1
  import { gsap } from 'gsap';
2
2
  import { BitmapText, Container as PIXIContainer, Sprite, Texture } from 'pixi.js';
3
- import { Container } from '../../display';
3
+ import { Container } from '../../display/Container';
4
4
  import { type Size } from '../../utils';
5
5
  import { CaptionsPlugin } from './CaptionsPlugin';
6
6
 
@@ -0,0 +1,124 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // Plugin.ts transitively imports Application → Pixi display graph. Stub it.
4
+ vi.mock('../core', () => ({
5
+ coreFunctionRegistry: {},
6
+ coreSignalRegistry: {},
7
+ }));
8
+ vi.mock('../core/Application', () => ({
9
+ Application: { getInstance: () => ({}) },
10
+ }));
11
+
12
+ import { Logger } from '../utils';
13
+ import type { i18nDict } from './i18nPlugin';
14
+ import { i18nPlugin } from './i18nPlugin';
15
+
16
+ /** Build an initialized plugin holding `dict` for the `en` locale. */
17
+ async function setup(dict: i18nDict) {
18
+ const plugin = new i18nPlugin();
19
+ await plugin.initialize({ defaultLocale: 'en', locales: ['en'] });
20
+ // `_dicts` is private and normally filled by loadLocale(); inject directly.
21
+ (plugin as unknown as { _dicts: Record<string, i18nDict> })._dicts = { en: dict };
22
+ return plugin;
23
+ }
24
+
25
+ /** Silence the expected Logger.error calls for missing keys. */
26
+ function muteErrors() {
27
+ return vi.spyOn(Logger, 'error').mockImplementation(() => undefined);
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ describe('i18nPlugin.t', () => {
35
+ it('returns the key itself when the key is missing', async () => {
36
+ const error = muteErrors();
37
+ const plugin = await setup({ hello: 'Hello' });
38
+ expect(plugin.t('nope.missing')).toBe('nope.missing');
39
+ expect(error).toHaveBeenCalled();
40
+ });
41
+
42
+ it('returns the key itself when no dictionary is loaded', async () => {
43
+ const error = muteErrors();
44
+ const plugin = await setup({ hello: 'Hello' });
45
+ expect(plugin.t('hello', undefined, 'fr')).toBe('hello');
46
+ expect(error).toHaveBeenCalled();
47
+ });
48
+
49
+ it('resolves variants to index 0 with no params', async () => {
50
+ const plugin = await setup({ greet: '[Hi|Hello|Yo] there' });
51
+ expect(plugin.t('greet')).toBe('Hi there');
52
+ });
53
+
54
+ it('never leaves brackets in the output', async () => {
55
+ const plugin = await setup({ greet: '[Hi|Hello] [friend|pal]' });
56
+ expect(plugin.t('greet')).not.toMatch(/[[\]]/);
57
+ expect(plugin.t('greet', { variant: 'random' })).not.toMatch(/[[\]]/);
58
+ });
59
+
60
+ it('applies a numeric variant to every group', async () => {
61
+ const plugin = await setup({ greet: '[Hi|Hello] [friend|pal]' });
62
+ expect(plugin.t('greet', { variant: 1 })).toBe('Hello pal');
63
+ });
64
+
65
+ it('clamps a numeric variant to each group last item', async () => {
66
+ const plugin = await setup({ greet: '[Hi|Hello|Yo] [friend|pal]' });
67
+ expect(plugin.t('greet', { variant: 5 })).toBe('Yo pal');
68
+ });
69
+
70
+ it('picks each random variant independently', async () => {
71
+ const plugin = await setup({ greet: '[a|b] [a|b]' });
72
+ const random = vi.spyOn(Math, 'random').mockReturnValueOnce(0).mockReturnValueOnce(0.9);
73
+ expect(plugin.t('greet', { variant: 'random' })).toBe('a b');
74
+ expect(random).toHaveBeenCalledTimes(2);
75
+ });
76
+
77
+ it('interpolates placeholders after variant resolution', async () => {
78
+ const plugin = await setup({ greet: '[Hi|Hello] {name}' });
79
+ expect(plugin.t('greet', { variant: 1, name: 'Ada' })).toBe('Hello Ada');
80
+ });
81
+ });
82
+
83
+ describe('i18nPlugin.tCount', () => {
84
+ const dict: i18nDict = {
85
+ apples: {
86
+ one: '{count} apple',
87
+ other: '{count} apples',
88
+ },
89
+ fish: {
90
+ other: '{count} fish',
91
+ },
92
+ guests: {
93
+ one: '[Just one|One] guest, {host}',
94
+ other: '{count} guests, {host}',
95
+ },
96
+ };
97
+
98
+ it('resolves the one category for en', async () => {
99
+ const plugin = await setup(dict);
100
+ expect(plugin.tCount('apples', 1)).toBe('1 apple');
101
+ });
102
+
103
+ it('resolves the other category for en', async () => {
104
+ const plugin = await setup(dict);
105
+ expect(plugin.tCount('apples', 2)).toBe('2 apples');
106
+ });
107
+
108
+ it('falls back to .other when the category leaf is missing', async () => {
109
+ const plugin = await setup(dict);
110
+ expect(plugin.tCount('fish', 1)).toBe('1 fish');
111
+ });
112
+
113
+ it('passes extra params through and still resolves variants', async () => {
114
+ const plugin = await setup(dict);
115
+ expect(plugin.tCount('guests', 1, { host: 'Ada' })).toBe('Just one guest, Ada');
116
+ expect(plugin.tCount('guests', 3, { host: 'Ada' })).toBe('3 guests, Ada');
117
+ });
118
+
119
+ it('returns the .other key when nothing resolves', async () => {
120
+ muteErrors();
121
+ const plugin = await setup(dict);
122
+ expect(plugin.tCount('pears', 2)).toBe('pears.other');
123
+ });
124
+ });
@@ -17,6 +17,17 @@ export type LocaleKey = AppTypeOverrides extends { LocaleKeys: infer K extends s
17
17
  ? K | (string & {})
18
18
  : string;
19
19
 
20
+ /**
21
+ * Base keys that have plural leaves (i.e. a `<base>.other` entry exists in the
22
+ * reference locale). These are the keys `tCount()` accepts.
23
+ *
24
+ * `(string & {})` preserves autocomplete for known keys while still
25
+ * accepting dynamic strings.
26
+ */
27
+ export type PluralLocaleKey = AppTypeOverrides extends { LocaleKeys: infer K extends string }
28
+ ? (K extends `${infer B}.other` ? B : never) | (string & {})
29
+ : string;
30
+
20
31
  /**
21
32
  * Type definition for i18n dictionary.
22
33
  */
@@ -73,6 +84,8 @@ export interface Ii18nPlugin extends IPlugin {
73
84
 
74
85
  t(key: LocaleKey, params?: i18nTParams, locale?: string): string;
75
86
 
87
+ tCount(key: PluralLocaleKey, count: number, params?: i18nTParams, locale?: string): string;
88
+
76
89
  parse(input: string, locale?: string): string;
77
90
  }
78
91
 
@@ -152,10 +165,13 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
152
165
 
153
166
  /**
154
167
  * Translates a key into a string.
155
- * If the key is not found, it will return an empty string.
168
+ * If the key is not found (or no dictionary is loaded), it returns the key itself, so a typo
169
+ * renders visibly on screen instead of blanking the UI.
156
170
  * If the key is found, it will replace any placeholders in the string with the values from the params object.
157
- * If the key contains a variant, it will select a random variant if the variant param is set to 'random'.
158
- * If the key contains a number variant, it will select the variant based on the variant param.
171
+ * Every `[a|b|c]` group in the string is resolved, whether or not params were passed:
172
+ * `variant: 'random'` picks a random item per group, `variant: <number>` picks that index for every
173
+ * group (clamped to the group's last item), and with no variant param index 0 is used. Brackets never
174
+ * survive into the output.
159
175
  * @param key The key to translate.
160
176
  * @param params The parameters to replace in the string.
161
177
  * @param locale The locale to use for translation.
@@ -166,7 +182,7 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
166
182
  const dict = this._dicts[locale];
167
183
  if (!dict) {
168
184
  Logger.error(`i18n:: No dictionary loaded for current locale: ${locale}`);
169
- return '';
185
+ return key as string;
170
186
  }
171
187
  // Dot-path resolution: `t('obj.nested')` walks the dict tree. Flat keys
172
188
  // still work (including keys that literally contain dots, via the
@@ -176,24 +192,26 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
176
192
 
177
193
  if (!str) {
178
194
  Logger.error(`i18n:: No result found for the key ${key} in the locale: ${this._locale}`);
179
- return '';
195
+ return key as string;
180
196
  }
181
197
 
182
- if (params) {
183
- if (typeof params.variant === 'number' || params.variant === 'random') {
184
- const match = /\[(.*?)\]/.exec(str);
185
- if (match) {
186
- // Split the string by the "|" character to get an array of variations.
187
- const items = match[1].split('|');
188
-
189
- // Get the selected variant based on the "variation" param.
190
- const num = params.variant === 'random' ? Math.floor(Math.random() * items.length) : params.variant;
198
+ // Resolve every `[a|b|c]` group, whether or not params were passed, so brackets
199
+ // never leak into the rendered string.
200
+ const variant = params?.variant;
201
+ str = str.replace(/\[(.*?)\]/g, (_match, group: string) => {
202
+ // Split the group by the "|" character to get an array of variations.
203
+ const items = group.split('|');
191
204
 
192
- // Replace the original string with the selected variant
193
- str = str.replace(match[0], items[num]);
194
- }
205
+ if (variant === 'random') {
206
+ return items[Math.floor(Math.random() * items.length)];
195
207
  }
196
208
 
209
+ // A number picks that index for every group, clamped to the group's last item.
210
+ const num = typeof variant === 'number' ? Math.min(Math.max(variant, 0), items.length - 1) : 0;
211
+ return items[num];
212
+ });
213
+
214
+ if (params) {
197
215
  // Iterate over all params to replace placeholders in the string.
198
216
  for (const f in params) {
199
217
  // Create a regular expression to match the placeholder for the current param.
@@ -221,6 +239,27 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
221
239
  return this.t(key, params, locale);
222
240
  }
223
241
 
242
+ /**
243
+ * Translates a pluralised key using the locale's own plural rules.
244
+ * The CLDR plural category for `count` (`one`, `other`, `few`, ...) is resolved against
245
+ * `<key>.<category>`, falling back to `<key>.other` when that leaf does not exist.
246
+ * `count` is passed through as a param, so `{count}` interpolates and variants still apply.
247
+ * @param key The base key holding the plural leaves.
248
+ * @param count The count used to pick the plural category.
249
+ * @param params Additional parameters to replace in the string.
250
+ * @param locale The locale to use for translation.
251
+ * @returns The translated string.
252
+ */
253
+ tCount(key: PluralLocaleKey, count: number, params?: i18nTParams, locale: string = this._locale): string {
254
+ const category = new Intl.PluralRules(locale).select(count);
255
+ const dict = this._dicts[locale];
256
+ let pluralKey = `${key}.${category}`;
257
+ if (!dict || typeof resolveLocalePath(dict, pluralKey) !== 'string') {
258
+ pluralKey = `${key}.other`;
259
+ }
260
+ return this.t(pluralKey, { count, ...params }, locale);
261
+ }
262
+
224
263
  /**
225
264
  * Parses the input string and replaces anything in between {} braces, assuming it is a key in the dictionary.
226
265
  * @param {string} input
@@ -263,7 +302,7 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
263
302
  }
264
303
 
265
304
  protected getCoreFunctions(): string[] {
266
- return ['t', 'translate', 'setLocale'];
305
+ return ['t', 'translate', 'tCount', 'setLocale'];
267
306
  }
268
307
 
269
308
  protected getCoreSignals(): string[] {
@@ -1,5 +1,5 @@
1
1
  import { Application } from '../../../core/Application';
2
- import { WithSignals } from '../../../mixins';
2
+ import { WithSignals } from '../../../mixins/signals';
3
3
  import { bindAllMethods } from '../../../utils';
4
4
  import type { Action } from '../../actions';
5
5
  import { normalizeKey, type KeyboardEventDetail } from '../../KeyboardPlugin';
@@ -3,8 +3,8 @@ import type { ControlsActionMap, JoystickControlsScheme, TouchControlsMap, Touch
3
3
 
4
4
  import { JoystickDirection } from '..';
5
5
  import { Application } from '../../../core/Application';
6
- import { WithSignals } from '../../../mixins';
7
- import { type IButton, type IJoystick } from '../../../ui';
6
+ import { WithSignals } from '../../../mixins/signals';
7
+ import type { IButton, IJoystick } from '../../../ui';
8
8
  import type { Action } from '../../actions';
9
9
  import { AbstractControls } from '../AbstractControls';
10
10
 
@@ -28,7 +28,9 @@
28
28
  *****************************************************************************/
29
29
 
30
30
  declare global {
31
- var require: any;
31
+ // @types/node (present via typeRoots) already declares `require: Require`;
32
+ // redeclaring it as `any` is a TS2403 conflict, so declare only what node
33
+ // doesn't, and keep the shim's loose typing at the use sites below.
32
34
  var PIXI: any;
33
35
  }
34
36