@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.
- package/build/index.mjs +2 -0
- package/build/plugins/viewport.mjs +52 -0
- package/lib/{CaptionsPlugin-DeGyPecB.js → CaptionsPlugin-WPAcfAkx.js} +13 -12
- package/lib/CaptionsPlugin-WPAcfAkx.js.map +1 -0
- package/lib/{DataAdapter-DeI2kIxC.js → DataAdapter-DS-twowe.js} +11 -11
- package/lib/{DataAdapter-DeI2kIxC.js.map → DataAdapter-DS-twowe.js.map} +1 -1
- package/lib/{DebugRenderer-wjV0tMwL.js → DebugRenderer-D1ryrRYx.js} +2 -2
- package/lib/{DebugRenderer-wjV0tMwL.js.map → DebugRenderer-D1ryrRYx.js.map} +1 -1
- package/lib/{DevToolsPlugin-D4iWQwkO.js → DevToolsPlugin-DH7zX7HU.js} +2 -2
- package/lib/{DevToolsPlugin-D4iWQwkO.js.map → DevToolsPlugin-DH7zX7HU.js.map} +1 -1
- package/lib/{GSAPPlugin-DQTyIwcC.js → GSAPPlugin-BBtRjUqe.js} +2 -2
- package/lib/{GSAPPlugin-DQTyIwcC.js.map → GSAPPlugin-BBtRjUqe.js.map} +1 -1
- package/lib/{LayoutPlugin-vR_h-BaG.js → LayoutPlugin-Co6vcoV4.js} +4 -4
- package/lib/{LayoutPlugin-vR_h-BaG.js.map → LayoutPlugin-Co6vcoV4.js.map} +1 -1
- package/lib/{SpinePlugin-DJ-PpxFj.js → SpinePlugin-uN641Hmk.js} +2 -2
- package/lib/{SpinePlugin-DJ-PpxFj.js.map → SpinePlugin-uN641Hmk.js.map} +1 -1
- package/lib/{StatsPlugin-BYNnKYOm.js → StatsPlugin-vZQRm6wh.js} +4 -4
- package/lib/{StatsPlugin-BYNnKYOm.js.map → StatsPlugin-vZQRm6wh.js.map} +1 -1
- package/lib/{VoiceOverPlugin-BLSbq3DU.js → VoiceOverPlugin-KlZdGj1G.js} +32 -32
- package/lib/{VoiceOverPlugin-BLSbq3DU.js.map → VoiceOverPlugin-KlZdGj1G.js.map} +1 -1
- package/lib/caper.mjs +2107 -353
- package/lib/caper.mjs.map +1 -1
- package/lib/core/Application.d.ts +7 -1
- package/lib/core/Application.d.ts.map +1 -1
- package/lib/core/interfaces/IApplicationOptions.d.ts +1 -1
- package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
- package/lib/core/interfaces/ICoreFunctions.d.ts +1 -0
- package/lib/core/interfaces/ICoreFunctions.d.ts.map +1 -1
- package/lib/display/AnimatedSprite.d.ts +1 -1
- package/lib/display/AnimatedSprite.d.ts.map +1 -1
- package/lib/display/Container.d.ts.map +1 -1
- package/lib/display/SpineAnimation.d.ts +1 -1
- package/lib/display/SpineAnimation.d.ts.map +1 -1
- package/lib/importOrder.containerFirst.test.d.ts +2 -0
- package/lib/importOrder.containerFirst.test.d.ts.map +1 -0
- package/lib/importOrder.displayFirst.test.d.ts +2 -0
- package/lib/importOrder.displayFirst.test.d.ts.map +1 -0
- package/lib/importOrder.mixinsFirst.test.d.ts +2 -0
- package/lib/importOrder.mixinsFirst.test.d.ts.map +1 -0
- package/lib/importOrder.sceneFirst.test.d.ts +2 -0
- package/lib/importOrder.sceneFirst.test.d.ts.map +1 -0
- package/lib/importOrder.toastFirst.test.d.ts +2 -0
- package/lib/importOrder.toastFirst.test.d.ts.map +1 -0
- package/lib/importOrder.uiFirst.test.d.ts +2 -0
- package/lib/importOrder.uiFirst.test.d.ts.map +1 -0
- package/lib/mixins/factory/Factory.d.ts +1 -1
- package/lib/mixins/factory/Factory.d.ts.map +1 -1
- package/lib/mixins/factory/Factory.test.d.ts +1 -0
- package/lib/mixins/factory/Factory.test.d.ts.map +1 -0
- package/lib/mixins/factory/const.d.ts +9 -2
- package/lib/mixins/factory/const.d.ts.map +1 -1
- package/lib/mixins/factory/defaults.d.ts +5 -0
- package/lib/mixins/factory/defaults.d.ts.map +1 -0
- package/lib/mixins/factory/methods.d.ts +1 -1
- package/lib/mixins/factory/methods.d.ts.map +1 -1
- package/lib/mixins/factory/props.d.ts +4 -2
- package/lib/mixins/factory/props.d.ts.map +1 -1
- package/lib/plugins/PopupManagerPlugin.d.ts +1 -1
- package/lib/plugins/PopupManagerPlugin.d.ts.map +1 -1
- package/lib/plugins/ResizerPlugin.d.ts +29 -0
- package/lib/plugins/ResizerPlugin.d.ts.map +1 -1
- package/lib/plugins/ResizerPlugin.test.d.ts +2 -0
- package/lib/plugins/ResizerPlugin.test.d.ts.map +1 -0
- package/lib/plugins/WebEventsPlugin.d.ts +5 -0
- package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
- package/lib/plugins/WebEventsPlugin.test.d.ts +2 -0
- package/lib/plugins/WebEventsPlugin.test.d.ts.map +1 -0
- package/lib/plugins/captions/CaptionsRenderer.d.ts +1 -1
- package/lib/plugins/captions/CaptionsRenderer.d.ts.map +1 -1
- package/lib/plugins/i18nPlugin.d.ts +29 -3
- package/lib/plugins/i18nPlugin.d.ts.map +1 -1
- package/lib/plugins/i18nPlugin.test.d.ts +2 -0
- package/lib/plugins/i18nPlugin.test.d.ts.map +1 -0
- package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
- package/lib/plugins/spine/pixi-spine/require-shim.d.ts +0 -1
- package/lib/plugins/spine/pixi-spine/require-shim.d.ts.map +1 -1
- package/lib/{registries-aN0y0Xha.js → registries-BLrxALZ0.js} +1124 -2801
- package/lib/registries-BLrxALZ0.js.map +1 -0
- package/lib/ui/Button.d.ts +1 -1
- package/lib/ui/Button.d.ts.map +1 -1
- package/lib/ui/FlexContainer.d.ts +1 -1
- package/lib/ui/FlexContainer.d.ts.map +1 -1
- package/lib/ui/Input.d.ts +2 -2
- package/lib/ui/Input.d.ts.map +1 -1
- package/lib/ui/Joystick.d.ts +1 -1
- package/lib/ui/Joystick.d.ts.map +1 -1
- package/lib/ui/Toast.d.ts +1 -1
- package/lib/ui/Toast.d.ts.map +1 -1
- package/lib/ui/UICanvas.d.ts +11 -1
- package/lib/ui/UICanvas.d.ts.map +1 -1
- package/lib/ui/UICanvas.test.d.ts +2 -0
- package/lib/ui/UICanvas.test.d.ts.map +1 -0
- package/lib/utils/types.d.ts +1 -2
- package/lib/utils/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/Application.ts +15 -2
- package/src/core/interfaces/IApplicationOptions.ts +1 -1
- package/src/core/interfaces/ICoreFunctions.ts +1 -0
- package/src/display/AnimatedSprite.ts +2 -1
- package/src/display/Container.ts +3 -1
- package/src/display/SpineAnimation.ts +3 -2
- package/src/importOrder.containerFirst.test.ts +32 -0
- package/src/importOrder.displayFirst.test.ts +19 -0
- package/src/importOrder.mixinsFirst.test.ts +18 -0
- package/src/importOrder.sceneFirst.test.ts +21 -0
- package/src/importOrder.toastFirst.test.ts +20 -0
- package/src/importOrder.uiFirst.test.ts +24 -0
- package/src/mixins/factory/Factory.test.ts +22 -0
- package/src/mixins/factory/Factory.ts +9 -2
- package/src/mixins/factory/const.ts +18 -11
- package/src/mixins/factory/defaults.ts +28 -0
- package/src/mixins/factory/methods.ts +1 -1
- package/src/mixins/factory/props.ts +5 -3
- package/src/plugins/LookupPlugin.ts +1 -1
- package/src/plugins/PopupManagerPlugin.ts +1 -1
- package/src/plugins/ResizerPlugin.test.ts +84 -0
- package/src/plugins/ResizerPlugin.ts +59 -1
- package/src/plugins/WebEventsPlugin.test.ts +75 -0
- package/src/plugins/WebEventsPlugin.ts +15 -0
- package/src/plugins/captions/CaptionsRenderer.ts +1 -1
- package/src/plugins/i18nPlugin.test.ts +124 -0
- package/src/plugins/i18nPlugin.ts +57 -18
- package/src/plugins/input/keyboard/KeyboardContols.ts +1 -1
- package/src/plugins/input/touch/VirtualControls.ts +2 -2
- package/src/plugins/spine/pixi-spine/require-shim.ts +3 -1
- package/src/ui/Button.ts +5 -1
- package/src/ui/FlexContainer.ts +3 -2
- package/src/ui/Input.ts +5 -2
- package/src/ui/Joystick.ts +1 -1
- package/src/ui/Toast.ts +2 -1
- package/src/ui/Toaster.ts +1 -1
- package/src/ui/UICanvas.test.ts +35 -0
- package/src/ui/UICanvas.ts +46 -8
- package/src/utils/types.ts +1 -1
- package/src/version.ts +1 -1
- package/lib/CaptionsPlugin-DeGyPecB.js.map +0 -1
- 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,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
|
|
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.
|
|
@@ -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
|
|
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
|
-
*
|
|
158
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|