@caperjs/core 0.1.1 → 0.2.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 (109) hide show
  1. package/README.md +1 -1
  2. package/{config → build}/assetpack.mjs +103 -45
  3. package/build/defaults.mjs +157 -0
  4. package/build/index.mjs +96 -0
  5. package/build/internal/ast.mjs +255 -0
  6. package/build/internal/buildFlags.mjs +50 -0
  7. package/build/internal/discovery.mjs +402 -0
  8. package/build/internal/manifest.mjs +38 -0
  9. package/build/internal/schema.mjs +65 -0
  10. package/build/internal/util.mjs +30 -0
  11. package/build/internal/validate.mjs +186 -0
  12. package/build/plugins/assetTypes.mjs +460 -0
  13. package/build/plugins/caperConfig.mjs +489 -0
  14. package/build/plugins/devHelper.mjs +31 -0
  15. package/build/plugins/lists.mjs +286 -0
  16. package/build/plugins/pruneFallbacks.mjs +150 -0
  17. package/build/plugins/pwa.mjs +240 -0
  18. package/build/plugins/runtime.mjs +129 -0
  19. package/cli.mjs +9 -6
  20. package/extras/llms.txt +23 -17
  21. package/lib/{CaptionsPlugin-Dc8fIrjy.js → CaptionsPlugin-DKhbmJvH.js} +15 -15
  22. package/lib/{CaptionsPlugin-Dc8fIrjy.js.map → CaptionsPlugin-DKhbmJvH.js.map} +1 -1
  23. package/lib/{DataAdapter-BY2114sp.js → DataAdapter-BZFTkFQ4.js} +4 -4
  24. package/lib/{DataAdapter-BY2114sp.js.map → DataAdapter-BZFTkFQ4.js.map} +1 -1
  25. package/lib/{DebugRenderer-BJMLILqI.js → DebugRenderer-C7X-nlXE.js} +2 -2
  26. package/lib/{DebugRenderer-BJMLILqI.js.map → DebugRenderer-C7X-nlXE.js.map} +1 -1
  27. package/lib/{DevToolsPlugin-wQ5rOkn0.js → DevToolsPlugin-P-atJ-5U.js} +2 -2
  28. package/lib/{DevToolsPlugin-wQ5rOkn0.js.map → DevToolsPlugin-P-atJ-5U.js.map} +1 -1
  29. package/lib/{GSAPPlugin-DHhSZKv8.js → GSAPPlugin-CeH9275z.js} +2 -2
  30. package/lib/{GSAPPlugin-DHhSZKv8.js.map → GSAPPlugin-CeH9275z.js.map} +1 -1
  31. package/lib/{LayoutPlugin-B-DF9SYM.js → LayoutPlugin-iZ3k6RsK.js} +4 -4
  32. package/lib/{LayoutPlugin-B-DF9SYM.js.map → LayoutPlugin-iZ3k6RsK.js.map} +1 -1
  33. package/lib/{SpinePlugin-Dz_3dljz.js → SpinePlugin-BWiavCpO.js} +2 -2
  34. package/lib/{SpinePlugin-Dz_3dljz.js.map → SpinePlugin-BWiavCpO.js.map} +1 -1
  35. package/lib/{StatsPlugin-Cg82JMBD.js → StatsPlugin-CBpzDJL0.js} +4 -4
  36. package/lib/{StatsPlugin-Cg82JMBD.js.map → StatsPlugin-CBpzDJL0.js.map} +1 -1
  37. package/lib/{VoiceOverPlugin-BR3ImT8C.js → VoiceOverPlugin-YaiKJtJ6.js} +26 -26
  38. package/lib/{VoiceOverPlugin-BR3ImT8C.js.map → VoiceOverPlugin-YaiKJtJ6.js.map} +1 -1
  39. package/lib/caper.mjs +95 -95
  40. package/lib/caper.mjs.map +1 -1
  41. package/lib/core/Application.d.ts +1 -1
  42. package/lib/core/Application.d.ts.map +1 -1
  43. package/lib/core/interfaces/IApplicationOptions.d.ts +2 -1
  44. package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
  45. package/lib/core/interfaces/ICoreSignals.d.ts +5 -1
  46. package/lib/core/interfaces/ICoreSignals.d.ts.map +1 -1
  47. package/lib/display/Container.d.ts +1 -1
  48. package/lib/display/Container.d.ts.map +1 -1
  49. package/lib/display/ParticleContainer.d.ts +1 -1
  50. package/lib/display/ParticleContainer.d.ts.map +1 -1
  51. package/lib/plugins/Plugin.d.ts +1 -1
  52. package/lib/plugins/Plugin.d.ts.map +1 -1
  53. package/lib/plugins/defaults.d.ts.map +1 -1
  54. package/lib/plugins/gesture/GesturePlugin.d.ts +58 -0
  55. package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -0
  56. package/lib/plugins/gesture/gestureMath.d.ts +32 -0
  57. package/lib/plugins/gesture/gestureMath.d.ts.map +1 -0
  58. package/lib/plugins/gesture/gestureMath.test.d.ts +2 -0
  59. package/lib/plugins/gesture/gestureMath.test.d.ts.map +1 -0
  60. package/lib/plugins/gesture/index.d.ts +3 -0
  61. package/lib/plugins/gesture/index.d.ts.map +1 -0
  62. package/lib/plugins/gesture/types.d.ts +45 -0
  63. package/lib/plugins/gesture/types.d.ts.map +1 -0
  64. package/lib/plugins/index.d.ts +1 -0
  65. package/lib/plugins/index.d.ts.map +1 -1
  66. package/lib/plugins/input/Controls.d.ts +5 -2
  67. package/lib/plugins/input/Controls.d.ts.map +1 -1
  68. package/lib/plugins/input/InputPlugin.d.ts +9 -2
  69. package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
  70. package/lib/plugins/input/interfaces.d.ts +5 -3
  71. package/lib/plugins/input/interfaces.d.ts.map +1 -1
  72. package/lib/plugins/input/touch/{TouchControls.d.ts → VirtualControls.d.ts} +5 -4
  73. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -0
  74. package/lib/plugins/input/touch/index.d.ts +1 -1
  75. package/lib/plugins/input/touch/index.d.ts.map +1 -1
  76. package/lib/{registries-CYCaYKZI.js → registries-qQmLeN0b.js} +505 -354
  77. package/lib/registries-qQmLeN0b.js.map +1 -0
  78. package/lib/types/caper-app.d.ts +1 -0
  79. package/lib/utils/bind.test.d.ts +2 -0
  80. package/lib/utils/bind.test.d.ts.map +1 -0
  81. package/package.json +33 -33
  82. package/src/core/Application.ts +1 -1
  83. package/src/core/interfaces/IApplicationOptions.ts +2 -0
  84. package/src/core/interfaces/ICoreSignals.ts +9 -0
  85. package/src/display/Container.ts +1 -1
  86. package/src/display/ParticleContainer.ts +1 -1
  87. package/src/plugins/Plugin.ts +1 -1
  88. package/src/plugins/defaults.ts +6 -0
  89. package/src/plugins/gesture/GesturePlugin.ts +215 -0
  90. package/src/plugins/gesture/gestureMath.test.ts +75 -0
  91. package/src/plugins/gesture/gestureMath.ts +59 -0
  92. package/src/plugins/gesture/index.ts +2 -0
  93. package/src/plugins/gesture/types.ts +54 -0
  94. package/src/plugins/index.ts +1 -0
  95. package/src/plugins/input/Controls.ts +9 -3
  96. package/src/plugins/input/InputPlugin.ts +61 -14
  97. package/src/plugins/input/interfaces.ts +5 -3
  98. package/src/plugins/input/touch/{TouchControls.ts → VirtualControls.ts} +4 -1
  99. package/src/plugins/input/touch/index.ts +1 -1
  100. package/src/types/caper-app.d.ts +1 -0
  101. package/src/utils/bind.test.ts +99 -0
  102. package/src/version.ts +1 -1
  103. package/templates/app/default/package.template.json +8 -4
  104. package/templates/app/default/vite.config.ts +15 -0
  105. package/LICENSE +0 -22
  106. package/cli/vite.mjs +0 -55
  107. package/config/vite.mjs +0 -2464
  108. package/lib/plugins/input/touch/TouchControls.d.ts.map +0 -1
  109. package/lib/registries-CYCaYKZI.js.map +0 -1
@@ -0,0 +1 @@
1
+ /* caper.config.ts not found, skipping augmentation. */
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bind.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind.test.d.ts","sourceRoot":"","sources":["../../src/utils/bind.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caperjs/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "title": "Caper",
5
5
  "description": "An opinionated HTML game framework built on PixiJS v8",
6
6
  "bin": {
@@ -12,35 +12,31 @@
12
12
  "lib",
13
13
  "src",
14
14
  "cli",
15
- "config/vite.mjs",
16
- "config/assetpack.mjs",
15
+ "build",
17
16
  "config/tsconfig.base.json",
18
- "config/.prettierrc.json",
19
17
  "extras/llms.txt",
20
18
  "extras/css/accessibility.css",
21
19
  "extras/css/loader.css",
22
20
  "extras/css/fullscreen.css",
23
21
  "templates",
24
22
  "types",
25
- "client.d.ts"
23
+ "client.d.ts",
24
+ "!build/**/*.test.mjs"
26
25
  ],
27
26
  "exports": {
28
27
  ".": {
29
28
  "types": "./lib/index.d.ts",
30
29
  "default": "./lib/caper.mjs"
31
30
  },
31
+ "./vite": {
32
+ "default": "./build/index.mjs"
33
+ },
32
34
  "./client": {
33
35
  "types": "./client.d.ts"
34
36
  },
35
37
  "./extras/llms.txt": {
36
38
  "default": "./extras/llms.txt"
37
39
  },
38
- "./config/assetpack": {
39
- "default": "./config/assetpack.mjs"
40
- },
41
- "./config/vite": {
42
- "default": "./config/vite.mjs"
43
- },
44
40
  "./tsconfig": {
45
41
  "default": "./config/tsconfig.base.json"
46
42
  },
@@ -55,6 +51,25 @@
55
51
  }
56
52
  },
57
53
  "sideEffects": false,
54
+ "scripts": {
55
+ "dev": "cd ./examples && pnpm dev",
56
+ "start": "cd ./examples && pnpm start",
57
+ "build": "pnpm generate-version && vite build",
58
+ "prepack": "pnpm build",
59
+ "visualize:examples": "cd ./examples && npx vite-bundle-visualizer",
60
+ "lint": "eslint \"src/**/*.ts\"",
61
+ "lint:fix": "eslint \"src/**/*.ts\" --fix",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "docs": "pnpm build:docs",
65
+ "build:docs": "cd ./docs && pnpm build",
66
+ "dev:docs": "cd ./docs && pnpm dev",
67
+ "typedoc": "ppnpm generate-version && typedoc --out typedoc src",
68
+ "generate-version": "node ./scripts/generate-version.mjs",
69
+ "clean": "node ./scripts/clean.mjs",
70
+ "update-package-deps": "node ./scripts/update-package-deps.mjs",
71
+ "build-packages": "node ./scripts/build-packages.mjs"
72
+ },
58
73
  "repository": {
59
74
  "type": "git",
60
75
  "url": "git+https://github.com/anthonysapp/caper.git"
@@ -73,13 +88,17 @@
73
88
  "standard-version": "^9.5.0",
74
89
  "turbo": "^2.0.3",
75
90
  "typedoc": "^0.28.1",
91
+ "vite": "^8.0.7",
76
92
  "vite-plugin-dts": "^4.5.3",
77
- "vitest": "^3"
93
+ "vitest": "^3",
94
+ "workbox-window": "^7.4.0"
78
95
  },
79
96
  "peerDependencies": {
80
97
  "@pixi/sound": "^6.0.1",
81
98
  "gsap": "^3.15.0",
82
- "pixi.js": "8.19.0"
99
+ "pixi.js": "8.19.0",
100
+ "vite": "^8.0.7",
101
+ "workbox-window": "^7.4.0"
83
102
  },
84
103
  "dependencies": {
85
104
  "@assetpack/core": "^1.7.0",
@@ -103,7 +122,6 @@
103
122
  "stats.js": "^0.17.0",
104
123
  "typed-signals": "^2.5.0",
105
124
  "typescript": "^5.9.0",
106
- "vite": "^8.0.7",
107
125
  "vite-bundle-visualizer": "^1.2.1",
108
126
  "vite-plugin-pwa": "^1.2.0",
109
127
  "vite-plugin-singlefile": "^2.3.2",
@@ -116,23 +134,5 @@
116
134
  "workbox-strategies": "^7.4.0",
117
135
  "workbox-window": "^7.4.0",
118
136
  "zod": "^4.3.6"
119
- },
120
- "scripts": {
121
- "dev": "cd ./examples && pnpm dev",
122
- "start": "cd ./examples && pnpm start",
123
- "build": "pnpm generate-version && vite build",
124
- "visualize:examples": "cd ./examples && npx vite-bundle-visualizer",
125
- "lint": "eslint \"src/**/*.ts\"",
126
- "lint:fix": "eslint \"src/**/*.ts\" --fix",
127
- "test": "vitest run",
128
- "test:watch": "vitest",
129
- "docs": "pnpm build:docs",
130
- "build:docs": "cd ./docs && pnpm build",
131
- "dev:docs": "cd ./docs && pnpm dev",
132
- "typedoc": "ppnpm generate-version && typedoc --out typedoc src",
133
- "generate-version": "node ./scripts/generate-version.mjs",
134
- "clean": "node ./scripts/clean.mjs",
135
- "update-package-deps": "node ./scripts/update-package-deps.mjs",
136
- "build-packages": "node ./scripts/build-packages.mjs"
137
137
  }
138
- }
138
+ }
@@ -140,7 +140,7 @@ export class Application extends PIXIPApplication implements IApplication {
140
140
  public static instance: IApplication;
141
141
 
142
142
  // method binding root
143
- private readonly __caper_method_binding_root = true;
143
+ private static readonly __caper_method_binding_root = true;
144
144
 
145
145
  // debug overlay (lazy, see getter)
146
146
  private _debugContainer: PIXIContainer;
@@ -2,6 +2,7 @@ import type {
2
2
  ActionMap,
3
3
  BreakpointsConfig,
4
4
  FocusManagerPluginOptions,
5
+ GesturePluginOptions,
5
6
  i18nOptions,
6
7
  InputManagerOptions,
7
8
  LoadSceneMethod,
@@ -66,6 +67,7 @@ export interface IApplicationOptions extends ApplicationOptions {
66
67
  scenesLocation: string;
67
68
  actions: Partial<ActionMap>;
68
69
  input: Partial<InputManagerOptions>;
70
+ gesture?: Partial<GesturePluginOptions>;
69
71
  focus: Partial<FocusManagerPluginOptions>;
70
72
  splash: Partial<SplashOptions>;
71
73
  defaultScene: AppTypeOverrides['Scenes'];
@@ -3,7 +3,11 @@ import type {
3
3
  ChannelMutedDetail,
4
4
  ChannelVolumeDetail,
5
5
  FocusChangeDetail,
6
+ GestureChangeDetail,
7
+ GestureEndDetail,
8
+ GestureStartDetail,
6
9
  IAudioInstance,
10
+ InputController,
7
11
  KeyboardEventDetail,
8
12
  PopupSignalDetail,
9
13
  SoundDetail,
@@ -43,6 +47,11 @@ export interface ICoreSignals {
43
47
  onGamepadDisconnected: Signal<(gamepad: Gamepad) => void>;
44
48
  onControllerActivated: Signal<(controller: string) => void>;
45
49
  onControllerDeactivated: Signal<(controller: string) => void>;
50
+ onControllerChanged: Signal<(controller: InputController) => void>;
51
+ // GesturePlugin;
52
+ onGestureStart: Signal<(detail: GestureStartDetail) => void>;
53
+ onGestureChange: Signal<(detail: GestureChangeDetail) => void>;
54
+ onGestureEnd: Signal<(detail: GestureEndDetail) => void>;
46
55
  // KeyboardManager;
47
56
  onGlobalKeyDown: Signal<(detail: KeyboardEventDetail) => void>;
48
57
  onGlobalKeyUp: Signal<(detail: KeyboardEventDetail) => void>;
@@ -60,7 +60,7 @@ export interface IContainer {
60
60
  * It represents a container for PIXI.js display objects.
61
61
  */
62
62
  export class Container extends Animated(WithSignals(Factory())) implements IContainer {
63
- private readonly __caper_method_binding_root = true;
63
+ private static readonly __caper_method_binding_root = true;
64
64
 
65
65
  public onDestroy: Signal<() => void> = new Signal();
66
66
 
@@ -35,7 +35,7 @@ export interface IParticleContainer {
35
35
  */
36
36
  export class ParticleContainer extends PIXIParticleContainer implements IParticleContainer {
37
37
  onDestroy: Signal<() => void> = new Signal();
38
- __caper_method_binding_root = true;
38
+ static __caper_method_binding_root = true;
39
39
  private __config: ParticleContainerConfig;
40
40
 
41
41
  /**
@@ -114,7 +114,7 @@ export interface PluginListItem {
114
114
  }
115
115
 
116
116
  export class Plugin<O = any> implements IPlugin<O> {
117
- private readonly __caper_method_binding_root = true;
117
+ private static readonly __caper_method_binding_root = true;
118
118
  // A collection of signal connections.
119
119
  protected _signalConnections: SignalConnections = new SignalConnections();
120
120
 
@@ -13,6 +13,7 @@ import { ActionsPlugin } from './actions';
13
13
  import { AudioManagerPlugin } from './audio';
14
14
  import { BreakpointPlugin } from './breakpoints';
15
15
  import { FocusManagerPlugin } from './focus';
16
+ import { GesturePlugin } from './gesture';
16
17
  import { i18nPlugin } from './i18nPlugin';
17
18
  import { InputPlugin } from './input';
18
19
 
@@ -62,6 +63,11 @@ export const defaultPlugins: ImportList<IPlugin> = [
62
63
  module: InputPlugin,
63
64
  namedExport: 'InputPlugin',
64
65
  },
66
+ {
67
+ id: 'gesture',
68
+ module: GesturePlugin,
69
+ namedExport: 'GesturePlugin',
70
+ },
65
71
  {
66
72
  id: 'keyboard',
67
73
  module: KeyboardPlugin,
@@ -0,0 +1,215 @@
1
+ import type { IApplication } from '../../core';
2
+ import { Signal } from '../../signals';
3
+ import type { IPlugin } from '../Plugin';
4
+ import { Plugin } from '../Plugin';
5
+ import type { GestureFrame, PointerSample } from './gestureMath';
6
+ import { computeFrame, frameDelta } from './gestureMath';
7
+ import type { GestureChangeDetail, GestureEndDetail, GesturePluginOptions, GestureStartDetail } from './types';
8
+ import { defaultGestureOptions } from './types';
9
+
10
+ type GestureState = 'idle' | 'pending' | 'active';
11
+
12
+ /** Below this, `totalScale` is reported as `1` rather than dividing by a near-zero spread. */
13
+ const SPREAD_EPSILON = 0.01;
14
+
15
+ export interface IGesturePlugin extends IPlugin<GesturePluginOptions> {
16
+ readonly onGestureStart: Signal<(detail: GestureStartDetail) => void>;
17
+ readonly onGestureChange: Signal<(detail: GestureChangeDetail) => void>;
18
+ readonly onGestureEnd: Signal<(detail: GestureEndDetail) => void>;
19
+ readonly isActive: boolean;
20
+ readonly pointerCount: number;
21
+ }
22
+
23
+ /**
24
+ * Multi-touch gesture recognizer: pinch zoom and two-finger pan combined
25
+ * into one gesture, the way map apps work. There is no mode and no toggle —
26
+ * a pinch that also drifts pans, a two-finger drag that also spreads zooms.
27
+ *
28
+ * One finger is never a camera gesture; games are expected to use it for
29
+ * aim/build/UI as normal. Rotation is deliberately not recognised.
30
+ *
31
+ * Listens on raw DOM pointer events — `pointerdown` on `app.canvas`,
32
+ * `pointermove`/`pointerup`/`pointercancel` on `window` — rather than PixiJS
33
+ * federated events, so it sees every pointer regardless of scene-graph hit
34
+ * testing, and lift-outside is always seen.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * app.signal.onGestureChange.connect(({ dx, dy, scale, centerX, centerY }) => {
39
+ * camera.pinchZoomAt(scale, centerX, centerY);
40
+ * camera.panBy(dx, dy);
41
+ * });
42
+ * ```
43
+ */
44
+ export class GesturePlugin extends Plugin<GesturePluginOptions> implements IGesturePlugin {
45
+ public readonly id = 'gesture';
46
+
47
+ public readonly onGestureStart = new Signal<(detail: GestureStartDetail) => void>();
48
+ public readonly onGestureChange = new Signal<(detail: GestureChangeDetail) => void>();
49
+ public readonly onGestureEnd = new Signal<(detail: GestureEndDetail) => void>();
50
+
51
+ private _pointers = new Map<number, PointerSample>();
52
+ private _state: GestureState = 'idle';
53
+ private _startFrame: GestureFrame | null = null;
54
+ private _lastFrame: GestureFrame | null = null;
55
+ private _canvas: HTMLCanvasElement | null = null;
56
+ private _previousTouchAction = '';
57
+
58
+ get isActive(): boolean {
59
+ return this._state === 'active';
60
+ }
61
+
62
+ get pointerCount(): number {
63
+ return this._pointers.size;
64
+ }
65
+
66
+ async initialize(options: Partial<GesturePluginOptions> = {}, app: IApplication): Promise<void> {
67
+ this._options = { ...defaultGestureOptions, ...options };
68
+ if (!this._options.enabled) return;
69
+
70
+ const canvas = app.canvas as HTMLCanvasElement;
71
+ this._canvas = canvas;
72
+ if (this._options.preventDefault) {
73
+ this._previousTouchAction = canvas.style.touchAction;
74
+ canvas.style.touchAction = 'none';
75
+ }
76
+
77
+ canvas.addEventListener('pointerdown', this._onPointerDown);
78
+ window.addEventListener('pointermove', this._onPointerMove);
79
+ window.addEventListener('pointerup', this._onPointerUp);
80
+ window.addEventListener('pointercancel', this._onPointerCancel);
81
+ }
82
+
83
+ public destroy(): void {
84
+ if (this._canvas) {
85
+ this._canvas.removeEventListener('pointerdown', this._onPointerDown);
86
+ if (this._options.preventDefault) {
87
+ this._canvas.style.touchAction = this._previousTouchAction;
88
+ }
89
+ this._canvas = null;
90
+ }
91
+ window.removeEventListener('pointermove', this._onPointerMove);
92
+ window.removeEventListener('pointerup', this._onPointerUp);
93
+ window.removeEventListener('pointercancel', this._onPointerCancel);
94
+
95
+ this._pointers.clear();
96
+ this._state = 'idle';
97
+ this._startFrame = null;
98
+ this._lastFrame = null;
99
+
100
+ this.onGestureStart.disconnectAll();
101
+ this.onGestureChange.disconnectAll();
102
+ this.onGestureEnd.disconnectAll();
103
+ super.destroy();
104
+ }
105
+
106
+ protected getCoreSignals(): string[] {
107
+ return ['onGestureStart', 'onGestureChange', 'onGestureEnd'];
108
+ }
109
+
110
+ private _onPointerDown = (e: PointerEvent): void => {
111
+ if (!this._options.pointerTypes!.includes(e.pointerType)) return;
112
+
113
+ const before = this._state;
114
+ const hadTwoOrMore = this._pointers.size >= 2;
115
+ this._pointers.set(e.pointerId, { id: e.pointerId, x: e.clientX, y: e.clientY });
116
+
117
+ if (this._state === 'idle' && this._pointers.size === 2) {
118
+ const frame = computeFrame([...this._pointers.values()]);
119
+ this._startFrame = frame;
120
+ this._lastFrame = frame;
121
+ this._state = 'pending';
122
+ } else if (this._state !== 'idle' && hadTwoOrMore) {
123
+ // a 3rd+ finger joined mid-gesture — rebase without emitting so it doesn't jump the frame
124
+ this._lastFrame = computeFrame([...this._pointers.values()]);
125
+ }
126
+
127
+ this._maybePreventDefault(e, before, this._state);
128
+ };
129
+
130
+ private _onPointerMove = (e: PointerEvent): void => {
131
+ if (!this._pointers.has(e.pointerId)) return;
132
+ this._pointers.set(e.pointerId, { id: e.pointerId, x: e.clientX, y: e.clientY });
133
+
134
+ const before = this._state;
135
+
136
+ if (this._pointers.size >= 2) {
137
+ const next = computeFrame([...this._pointers.values()]);
138
+
139
+ if (this._state === 'pending') {
140
+ const delta = frameDelta(this._lastFrame!, next);
141
+ const spreadDelta = Math.abs(next.spread - this._lastFrame!.spread);
142
+ const centroidDelta = Math.hypot(delta.dx, delta.dy);
143
+ if (spreadDelta >= this._options.pinchThreshold! || centroidDelta >= this._options.panThreshold!) {
144
+ this._lastFrame = next; // rebase — discard the threshold slack, no jump
145
+ this._state = 'active';
146
+ this.onGestureStart.emit({
147
+ centerX: next.centerX,
148
+ centerY: next.centerY,
149
+ pointerCount: this._pointers.size,
150
+ });
151
+ }
152
+ } else if (this._state === 'active') {
153
+ const delta = frameDelta(this._lastFrame!, next);
154
+ const totalScale = this._computeTotalScale(next);
155
+ this._lastFrame = next;
156
+ this.onGestureChange.emit({
157
+ centerX: next.centerX,
158
+ centerY: next.centerY,
159
+ dx: delta.dx,
160
+ dy: delta.dy,
161
+ scale: delta.scale,
162
+ totalScale,
163
+ pointerCount: this._pointers.size,
164
+ });
165
+ }
166
+ }
167
+
168
+ this._maybePreventDefault(e, before, this._state);
169
+ };
170
+
171
+ private _onPointerUp = (e: PointerEvent): void => {
172
+ this._endPointer(e);
173
+ };
174
+
175
+ private _onPointerCancel = (e: PointerEvent): void => {
176
+ this._endPointer(e);
177
+ };
178
+
179
+ private _endPointer(e: PointerEvent): void {
180
+ if (!this._pointers.has(e.pointerId)) return;
181
+
182
+ const before = this._state;
183
+ this._pointers.delete(e.pointerId);
184
+
185
+ if (this._pointers.size < 2) {
186
+ if (this._state === 'active' && this._lastFrame) {
187
+ this.onGestureEnd.emit({
188
+ centerX: this._lastFrame.centerX,
189
+ centerY: this._lastFrame.centerY,
190
+ totalScale: this._computeTotalScale(this._lastFrame),
191
+ });
192
+ }
193
+ this._state = 'idle';
194
+ this._startFrame = null;
195
+ this._lastFrame = null;
196
+ } else if (this._state !== 'idle') {
197
+ // a finger lifted but 2+ remain — rebase without emitting so it doesn't jump the frame
198
+ this._lastFrame = computeFrame([...this._pointers.values()]);
199
+ }
200
+
201
+ this._maybePreventDefault(e, before, this._state);
202
+ }
203
+
204
+ private _computeTotalScale(frame: GestureFrame): number {
205
+ if (!this._startFrame || this._startFrame.spread < SPREAD_EPSILON) return 1;
206
+ return frame.spread / this._startFrame.spread;
207
+ }
208
+
209
+ /** Preventable only for tracked pointer events while a gesture is pending or active. */
210
+ private _maybePreventDefault(e: PointerEvent, before: GestureState, after: GestureState): void {
211
+ if (this._options.preventDefault && (before !== 'idle' || after !== 'idle')) {
212
+ e.preventDefault();
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { computeFrame, frameDelta } from './gestureMath';
4
+ import type { PointerSample } from './gestureMath';
5
+
6
+ describe('computeFrame', () => {
7
+ it('returns a zero frame for no pointers', () => {
8
+ expect(computeFrame([])).toEqual({ centerX: 0, centerY: 0, spread: 0 });
9
+ });
10
+
11
+ it('centers on the single pointer with zero spread', () => {
12
+ const p: PointerSample[] = [{ id: 1, x: 10, y: 20 }];
13
+ expect(computeFrame(p)).toEqual({ centerX: 10, centerY: 20, spread: 0 });
14
+ });
15
+
16
+ it('computes the centroid of two pointers', () => {
17
+ const p: PointerSample[] = [
18
+ { id: 1, x: 0, y: 0 },
19
+ { id: 2, x: 100, y: 0 },
20
+ ];
21
+ const frame = computeFrame(p);
22
+ expect(frame.centerX).toBe(50);
23
+ expect(frame.centerY).toBe(0);
24
+ });
25
+
26
+ it('spread is half the pair distance for two pointers', () => {
27
+ const p: PointerSample[] = [
28
+ { id: 1, x: 0, y: 0 },
29
+ { id: 2, x: 100, y: 0 },
30
+ ];
31
+ expect(computeFrame(p).spread).toBe(50);
32
+ });
33
+
34
+ it('generalises spread to 3+ pointers as the mean radial distance', () => {
35
+ const p: PointerSample[] = [
36
+ { id: 1, x: 0, y: 0 },
37
+ { id: 2, x: 100, y: 0 },
38
+ { id: 3, x: 50, y: 100 },
39
+ ];
40
+ // centroid = (50, 33.33...)
41
+ const frame = computeFrame(p);
42
+ expect(frame.centerX).toBeCloseTo(50);
43
+ expect(frame.centerY).toBeCloseTo(33.333, 2);
44
+ expect(frame.spread).toBeGreaterThan(0);
45
+ });
46
+ });
47
+
48
+ describe('frameDelta', () => {
49
+ it('computes centroid translation', () => {
50
+ const prev = { centerX: 0, centerY: 0, spread: 50 };
51
+ const next = { centerX: 10, centerY: -5, spread: 50 };
52
+ const delta = frameDelta(prev, next);
53
+ expect(delta.dx).toBe(10);
54
+ expect(delta.dy).toBe(-5);
55
+ expect(delta.scale).toBe(1);
56
+ });
57
+
58
+ it('computes the spread ratio as scale', () => {
59
+ const prev = { centerX: 0, centerY: 0, spread: 50 };
60
+ const next = { centerX: 0, centerY: 0, spread: 100 };
61
+ expect(frameDelta(prev, next).scale).toBe(2);
62
+ });
63
+
64
+ it('guards a near-zero previous spread by returning scale: 1', () => {
65
+ const prev = { centerX: 0, centerY: 0, spread: 0 };
66
+ const next = { centerX: 0, centerY: 0, spread: 100 };
67
+ expect(frameDelta(prev, next).scale).toBe(1);
68
+ });
69
+
70
+ it('guards a previous spread just under the epsilon', () => {
71
+ const prev = { centerX: 0, centerY: 0, spread: 0.005 };
72
+ const next = { centerX: 0, centerY: 0, spread: 50 };
73
+ expect(frameDelta(prev, next).scale).toBe(1);
74
+ });
75
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pure multi-touch gesture math — centroid, spread and frame-to-frame deltas.
3
+ * No Pixi/caper imports so it stays trivially unit-testable.
4
+ */
5
+
6
+ export interface PointerSample {
7
+ id: number;
8
+ x: number;
9
+ y: number;
10
+ }
11
+
12
+ export interface GestureFrame {
13
+ centerX: number;
14
+ centerY: number;
15
+ /** Mean distance from the centroid across all tracked pointers. */
16
+ spread: number;
17
+ }
18
+
19
+ /** Below this, `frameDelta` treats the previous spread as unusable and returns `scale: 1`. */
20
+ const SPREAD_EPSILON = 0.01;
21
+
22
+ /**
23
+ * Centroid and mean radial spread of a set of pointers. For two pointers,
24
+ * `spread` is half the pair distance; since only the *ratio* between two
25
+ * frames is ever used, this generalises to 3+ fingers for free.
26
+ */
27
+ export function computeFrame(pointers: PointerSample[]): GestureFrame {
28
+ const count = pointers.length;
29
+ if (count === 0) return { centerX: 0, centerY: 0, spread: 0 };
30
+
31
+ let sumX = 0;
32
+ let sumY = 0;
33
+ for (const p of pointers) {
34
+ sumX += p.x;
35
+ sumY += p.y;
36
+ }
37
+ const centerX = sumX / count;
38
+ const centerY = sumY / count;
39
+
40
+ let sumDist = 0;
41
+ for (const p of pointers) {
42
+ sumDist += Math.hypot(p.x - centerX, p.y - centerY);
43
+ }
44
+ const spread = sumDist / count;
45
+
46
+ return { centerX, centerY, spread };
47
+ }
48
+
49
+ /**
50
+ * Centroid translation and spread ratio between two frames. `scale` is
51
+ * guarded to `1` when `prev.spread` is too small to divide by, so a
52
+ * near-zero starting spread can't produce a huge or infinite jump.
53
+ */
54
+ export function frameDelta(prev: GestureFrame, next: GestureFrame): { dx: number; dy: number; scale: number } {
55
+ const dx = next.centerX - prev.centerX;
56
+ const dy = next.centerY - prev.centerY;
57
+ const scale = prev.spread < SPREAD_EPSILON ? 1 : next.spread / prev.spread;
58
+ return { dx, dy, scale };
59
+ }
@@ -0,0 +1,2 @@
1
+ export * from './GesturePlugin';
2
+ export * from './types';
@@ -0,0 +1,54 @@
1
+ /** The shape of the `gesture:` key in `caper.config.ts`, and the plugin's resolved options. */
2
+ export interface GesturePluginOptions {
3
+ /** @default true */
4
+ enabled?: boolean;
5
+ /** @default ['touch'] */
6
+ pointerTypes?: string[];
7
+ /** Pixels of spread change to cross before a pending gesture goes active. @default 8 */
8
+ pinchThreshold?: number;
9
+ /** Pixels of centroid movement to cross before a pending gesture goes active. @default 8 */
10
+ panThreshold?: number;
11
+ /** Also sets `canvas.style.touchAction = 'none'` while enabled. @default true */
12
+ preventDefault?: boolean;
13
+ }
14
+
15
+ export const defaultGestureOptions: Required<GesturePluginOptions> = {
16
+ enabled: true,
17
+ pointerTypes: ['touch'],
18
+ pinchThreshold: 8,
19
+ panThreshold: 8,
20
+ preventDefault: true,
21
+ };
22
+
23
+ export interface GestureStartDetail {
24
+ /** Client CSS pixels. */
25
+ centerX: number;
26
+ /** Client CSS pixels. */
27
+ centerY: number;
28
+ pointerCount: number;
29
+ }
30
+
31
+ export interface GestureChangeDetail {
32
+ /** Client CSS pixels. */
33
+ centerX: number;
34
+ /** Client CSS pixels. */
35
+ centerY: number;
36
+ /** Client-pixel delta since the previous frame. */
37
+ dx: number;
38
+ /** Client-pixel delta since the previous frame. */
39
+ dy: number;
40
+ /** Spread ratio since the previous frame. */
41
+ scale: number;
42
+ /** Spread ratio since the gesture started. */
43
+ totalScale: number;
44
+ pointerCount: number;
45
+ }
46
+
47
+ export interface GestureEndDetail {
48
+ /** Client CSS pixels. */
49
+ centerX: number;
50
+ /** Client CSS pixels. */
51
+ centerY: number;
52
+ /** Spread ratio since the gesture started. */
53
+ totalScale: number;
54
+ }
@@ -5,6 +5,7 @@ export * from './DataAdapter';
5
5
  export * from './audio';
6
6
  export * from './focus';
7
7
  export * from './FullScreenPlugin';
8
+ export * from './gesture';
8
9
  export * from './i18nPlugin';
9
10
  export * from './input';
10
11
  export * from './KeyboardPlugin';