@worldmesh/runtime 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/abilities/abilities.d.ts +9 -0
  4. package/dist/abilities/abilities.d.ts.map +1 -0
  5. package/dist/abilities/abilities.js +25 -0
  6. package/dist/abilities/abilities.js.map +1 -0
  7. package/dist/camera/cameraRig.d.ts +39 -0
  8. package/dist/camera/cameraRig.d.ts.map +1 -0
  9. package/dist/camera/cameraRig.js +88 -0
  10. package/dist/camera/cameraRig.js.map +1 -0
  11. package/dist/controls/input.d.ts +64 -0
  12. package/dist/controls/input.d.ts.map +1 -0
  13. package/dist/controls/input.js +236 -0
  14. package/dist/controls/input.js.map +1 -0
  15. package/dist/controls/keymap.d.ts +8 -0
  16. package/dist/controls/keymap.d.ts.map +1 -0
  17. package/dist/controls/keymap.js +21 -0
  18. package/dist/controls/keymap.js.map +1 -0
  19. package/dist/controls/touch.d.ts +43 -0
  20. package/dist/controls/touch.d.ts.map +1 -0
  21. package/dist/controls/touch.js +329 -0
  22. package/dist/controls/touch.js.map +1 -0
  23. package/dist/core/events.d.ts +9 -0
  24. package/dist/core/events.d.ts.map +1 -0
  25. package/dist/core/events.js +27 -0
  26. package/dist/core/events.js.map +1 -0
  27. package/dist/core/worldmesh.d.ts +10 -0
  28. package/dist/core/worldmesh.d.ts.map +1 -0
  29. package/dist/core/worldmesh.js +283 -0
  30. package/dist/core/worldmesh.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +18 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/movement/collision.d.ts +39 -0
  36. package/dist/movement/collision.d.ts.map +1 -0
  37. package/dist/movement/collision.js +93 -0
  38. package/dist/movement/collision.js.map +1 -0
  39. package/dist/movement/controller.d.ts +53 -0
  40. package/dist/movement/controller.d.ts.map +1 -0
  41. package/dist/movement/controller.js +200 -0
  42. package/dist/movement/controller.js.map +1 -0
  43. package/dist/net/adapter.d.ts +22 -0
  44. package/dist/net/adapter.d.ts.map +1 -0
  45. package/dist/net/adapter.js +8 -0
  46. package/dist/net/adapter.js.map +1 -0
  47. package/dist/player/avatar.d.ts +29 -0
  48. package/dist/player/avatar.d.ts.map +1 -0
  49. package/dist/player/avatar.js +420 -0
  50. package/dist/player/avatar.js.map +1 -0
  51. package/dist/player/player.d.ts +30 -0
  52. package/dist/player/player.d.ts.map +1 -0
  53. package/dist/player/player.js +76 -0
  54. package/dist/player/player.js.map +1 -0
  55. package/dist/portals/portals.d.ts +34 -0
  56. package/dist/portals/portals.d.ts.map +1 -0
  57. package/dist/portals/portals.js +132 -0
  58. package/dist/portals/portals.js.map +1 -0
  59. package/dist/types.d.ts +228 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/ui/overlay.d.ts +19 -0
  64. package/dist/ui/overlay.d.ts.map +1 -0
  65. package/dist/ui/overlay.js +370 -0
  66. package/dist/ui/overlay.js.map +1 -0
  67. package/package.json +63 -0
  68. package/src/abilities/abilities.ts +30 -0
  69. package/src/camera/cameraRig.ts +106 -0
  70. package/src/controls/input.ts +252 -0
  71. package/src/controls/keymap.ts +23 -0
  72. package/src/controls/touch.ts +378 -0
  73. package/src/core/events.ts +31 -0
  74. package/src/core/worldmesh.ts +313 -0
  75. package/src/index.ts +46 -0
  76. package/src/movement/collision.ts +109 -0
  77. package/src/movement/controller.ts +248 -0
  78. package/src/net/adapter.ts +28 -0
  79. package/src/player/avatar.ts +495 -0
  80. package/src/player/player.ts +91 -0
  81. package/src/portals/portals.ts +173 -0
  82. package/src/types.ts +230 -0
  83. package/src/ui/overlay.ts +398 -0
@@ -0,0 +1,252 @@
1
+ import type { InputAction, Keymap } from '../types.js';
2
+ import { resolveKeymap } from './keymap.js';
3
+ import { TouchControls, isTouchDevice } from './touch.js';
4
+
5
+ export interface InputOptions {
6
+ /** The element that receives pointer lock. */
7
+ element: HTMLElement;
8
+ keymap?: Partial<Keymap>;
9
+ onPointerLockChange?: (locked: boolean) => void;
10
+ }
11
+
12
+ /**
13
+ * Keyboard + pointer-lock mouse input with mobile touch controls support.
14
+ * This is the only place in WorldMesh that reads raw browser events, so the
15
+ * control convention lives in one file instead of being copy-pasted into every world.
16
+ */
17
+ export class Input {
18
+ readonly keymap: Keymap;
19
+ /** Accumulated mouse movement since the last read, in pixels. */
20
+ mouseDeltaX = 0;
21
+ mouseDeltaY = 0;
22
+ /** Accumulated wheel movement since the last read. */
23
+ wheelDelta = 0;
24
+ locked = false;
25
+
26
+ private element: HTMLElement;
27
+ private pressed = new Set<string>();
28
+ /** Actions that went down this frame and have not been consumed yet. */
29
+ private justPressed = new Set<InputAction>();
30
+ /** The last number key pressed this frame, if any. */
31
+ private pendingDigit: number | null = null;
32
+ private codeToActions = new Map<string, InputAction[]>();
33
+ private onPointerLockChange?: (locked: boolean) => void;
34
+ private touch?: TouchControls;
35
+ private disposed = false;
36
+
37
+ constructor(options: InputOptions) {
38
+ this.element = options.element;
39
+ this.keymap = resolveKeymap(options.keymap);
40
+ this.onPointerLockChange = options.onPointerLockChange;
41
+
42
+ for (const action of Object.keys(this.keymap) as InputAction[]) {
43
+ for (const code of this.keymap[action]) {
44
+ const list = this.codeToActions.get(code) ?? [];
45
+ list.push(action);
46
+ this.codeToActions.set(code, list);
47
+ }
48
+ }
49
+
50
+ // Touch controls on mobile / touchscreens
51
+ this.element.style.touchAction = 'none';
52
+ if (typeof window !== 'undefined') {
53
+ this.touch = new TouchControls({
54
+ element: this.element,
55
+ onExit: () => this.exitPointerLock(),
56
+ });
57
+ }
58
+
59
+ window.addEventListener('keydown', this.handleKeyDown);
60
+ window.addEventListener('keyup', this.handleKeyUp);
61
+ window.addEventListener('blur', this.handleBlur);
62
+ document.addEventListener('pointerlockchange', this.handlePointerLockChange);
63
+ document.addEventListener('pointerlockerror', this.handlePointerLockError);
64
+ this.element.addEventListener('mousemove', this.handleMouseMove);
65
+ this.element.addEventListener('wheel', this.handleWheel, { passive: true });
66
+ }
67
+
68
+ requestPointerLock(touchTriggered = false): void {
69
+ if (this.disposed || this.locked) return;
70
+
71
+ // Mobile / touch devices do not support pointer lock. Enter directly.
72
+ if (touchTriggered || isTouchDevice()) {
73
+ this.setLocked(true);
74
+ return;
75
+ }
76
+
77
+ if (typeof this.element.requestPointerLock === 'function') {
78
+ try {
79
+ const promise = this.element.requestPointerLock();
80
+ if (promise && typeof (promise as unknown as Promise<void>).then === 'function') {
81
+ (promise as unknown as Promise<void>).catch(() => {
82
+ // Pointer lock rejected (e.g. mobile Safari, tablet, iframe restrictions)
83
+ this.setLocked(true);
84
+ });
85
+ return;
86
+ }
87
+ } catch {
88
+ this.setLocked(true);
89
+ return;
90
+ }
91
+ } else {
92
+ this.setLocked(true);
93
+ }
94
+ }
95
+
96
+ setLocked(locked: boolean): void {
97
+ if (this.locked === locked) return;
98
+ this.locked = locked;
99
+ if (!locked) {
100
+ this.pressed.clear();
101
+ this.justPressed.clear();
102
+ this.touch?.reset();
103
+ }
104
+ this.touch?.setVisible(locked && isTouchDevice());
105
+ this.onPointerLockChange?.(this.locked);
106
+ }
107
+
108
+ exitPointerLock(): void {
109
+ if (document.pointerLockElement === this.element) {
110
+ document.exitPointerLock();
111
+ }
112
+ this.setLocked(false);
113
+ }
114
+
115
+ triggerAction(action: InputAction): void {
116
+ this.justPressed.add(action);
117
+ }
118
+
119
+ isDown(action: InputAction): boolean {
120
+ if (this.touch?.isDown(action)) return true;
121
+ for (const code of this.keymap[action]) if (this.pressed.has(code)) return true;
122
+ return false;
123
+ }
124
+
125
+ /** True once per key press. Reading it clears the flag. */
126
+ consume(action: InputAction): boolean {
127
+ if (this.touch?.consume(action)) return true;
128
+ if (!this.justPressed.has(action)) return false;
129
+ this.justPressed.delete(action);
130
+ return true;
131
+ }
132
+
133
+ /** The number key (0–9) pressed this frame, once. Reading it clears it. */
134
+ consumeDigit(): number | null {
135
+ const digit = this.pendingDigit;
136
+ this.pendingDigit = null;
137
+ return digit;
138
+ }
139
+
140
+ /** Movement intent on the local XZ plane, already normalized. */
141
+ getMoveAxis(): { x: number; z: number } {
142
+ let x = (this.isDown('right') ? 1 : 0) - (this.isDown('left') ? 1 : 0);
143
+ let z = (this.isDown('backward') ? 1 : 0) - (this.isDown('forward') ? 1 : 0);
144
+
145
+ if (this.touch) {
146
+ const touchAxis = this.touch.getMoveAxis();
147
+ x += touchAxis.x;
148
+ z += touchAxis.z;
149
+ }
150
+
151
+ const length = Math.hypot(x, z);
152
+ if (length === 0) return { x: 0, z: 0 };
153
+ if (length > 1) return { x: x / length, z: z / length };
154
+ return { x, z };
155
+ }
156
+
157
+ /** Read and reset the mouse/wheel/touch accumulators. Call once per frame. */
158
+ readLook(): { dx: number; dy: number; wheel: number } {
159
+ let dx = this.mouseDeltaX;
160
+ let dy = this.mouseDeltaY;
161
+ if (this.touch) {
162
+ const touchLook = this.touch.readLook();
163
+ dx += touchLook.dx;
164
+ dy += touchLook.dy;
165
+ }
166
+ const look = { dx, dy, wheel: this.wheelDelta };
167
+ this.mouseDeltaX = 0;
168
+ this.mouseDeltaY = 0;
169
+ this.wheelDelta = 0;
170
+ return look;
171
+ }
172
+
173
+ /** Drop stale edge-triggers at the end of a frame. */
174
+ endFrame(): void {
175
+ this.justPressed.clear();
176
+ this.pendingDigit = null;
177
+ }
178
+
179
+ dispose(): void {
180
+ this.disposed = true;
181
+ this.touch?.dispose();
182
+ window.removeEventListener('keydown', this.handleKeyDown);
183
+ window.removeEventListener('keyup', this.handleKeyUp);
184
+ window.removeEventListener('blur', this.handleBlur);
185
+ document.removeEventListener('pointerlockchange', this.handlePointerLockChange);
186
+ document.removeEventListener('pointerlockerror', this.handlePointerLockError);
187
+ this.element.removeEventListener('mousemove', this.handleMouseMove);
188
+ this.element.removeEventListener('wheel', this.handleWheel);
189
+ this.pressed.clear();
190
+ this.justPressed.clear();
191
+ }
192
+
193
+ private handleKeyDown = (event: KeyboardEvent): void => {
194
+ if (event.repeat) return;
195
+ if (isTypingTarget(event.target)) return;
196
+ const actions = this.codeToActions.get(event.code);
197
+ if (!actions) {
198
+ // Unbound number keys pick a face for the avatar.
199
+ const digit = /^(?:Digit|Numpad)(\d)$/.exec(event.code);
200
+ if (digit) this.pendingDigit = Number(digit[1]);
201
+ return;
202
+ }
203
+ // Space and the arrow keys scroll the page otherwise.
204
+ if (event.code === 'Space' || event.code.startsWith('Arrow')) event.preventDefault();
205
+ this.pressed.add(event.code);
206
+ for (const action of actions) this.justPressed.add(action);
207
+ };
208
+
209
+ private handleKeyUp = (event: KeyboardEvent): void => {
210
+ this.pressed.delete(event.code);
211
+ };
212
+
213
+ /** Losing focus must not leave a key stuck down. */
214
+ private handleBlur = (): void => {
215
+ this.pressed.clear();
216
+ this.justPressed.clear();
217
+ };
218
+
219
+ private handleMouseMove = (event: MouseEvent): void => {
220
+ if (!this.locked) return;
221
+ this.mouseDeltaX += event.movementX;
222
+ this.mouseDeltaY += event.movementY;
223
+ };
224
+
225
+ private handleWheel = (event: WheelEvent): void => {
226
+ if (!this.locked) return;
227
+ this.wheelDelta += event.deltaY;
228
+ };
229
+
230
+ private handlePointerLockChange = (): void => {
231
+ const isLocked = document.pointerLockElement === this.element;
232
+ if (isLocked) {
233
+ this.setLocked(true);
234
+ } else if (!isTouchDevice()) {
235
+ // On non-touch desktop, losing pointer lock releases controls
236
+ this.setLocked(false);
237
+ }
238
+ };
239
+
240
+ private handlePointerLockError = (): void => {
241
+ // If pointer lock failed on desktop / iframe, still let the user enter
242
+ if (!this.locked) {
243
+ this.setLocked(true);
244
+ }
245
+ };
246
+ }
247
+
248
+ function isTypingTarget(target: EventTarget | null): boolean {
249
+ if (!(target instanceof HTMLElement)) return false;
250
+ const tag = target.tagName;
251
+ return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
252
+ }
@@ -0,0 +1,23 @@
1
+ import type { Keymap } from '../types.js';
2
+
3
+ /**
4
+ * The WorldMesh control convention. Every world ships these bindings so that
5
+ * moving through an unfamiliar world feels like moving through a familiar one.
6
+ */
7
+ export const DEFAULT_KEYMAP: Keymap = {
8
+ forward: ['KeyW', 'ArrowUp'],
9
+ backward: ['KeyS', 'ArrowDown'],
10
+ left: ['KeyA', 'ArrowLeft'],
11
+ right: ['KeyD', 'ArrowRight'],
12
+ jump: ['Space'],
13
+ sprint: ['ShiftLeft', 'ShiftRight'],
14
+ interact: ['KeyE'],
15
+ toggleView: ['KeyV'],
16
+ crouch: ['KeyC'],
17
+ fly: ['KeyF'],
18
+ dash: ['KeyQ'],
19
+ };
20
+
21
+ export function resolveKeymap(overrides: Partial<Keymap> = {}): Keymap {
22
+ return { ...DEFAULT_KEYMAP, ...overrides };
23
+ }
@@ -0,0 +1,378 @@
1
+ import type { InputAction } from '../types.js';
2
+
3
+ export function isTouchDevice(): boolean {
4
+ if (typeof window === 'undefined') return false;
5
+ return (
6
+ 'ontouchstart' in window ||
7
+ navigator.maxTouchPoints > 0 ||
8
+ (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches)
9
+ );
10
+ }
11
+
12
+ const TOUCH_STYLE_ID = 'worldmesh-touch-style';
13
+
14
+ const TOUCH_CSS = `
15
+ .wm-touch-root {
16
+ position: fixed;
17
+ inset: 0;
18
+ pointer-events: none;
19
+ z-index: 2147482990;
20
+ user-select: none;
21
+ -webkit-user-select: none;
22
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
23
+ }
24
+ .wm-touch-joystick-base {
25
+ position: absolute;
26
+ width: 112px;
27
+ height: 112px;
28
+ margin: -56px 0 0 -56px;
29
+ border-radius: 50%;
30
+ border: 2px solid rgba(255, 255, 255, 0.28);
31
+ background: rgba(8, 11, 16, 0.4);
32
+ backdrop-filter: blur(4px);
33
+ pointer-events: none;
34
+ will-change: transform;
35
+ transition: opacity .15s ease;
36
+ }
37
+ /* Resting in the bottom-left corner so players can see where to put a thumb. */
38
+ .wm-touch-joystick-base.wm-idle {
39
+ left: calc(env(safe-area-inset-left, 0px) + 84px);
40
+ top: calc(100% - env(safe-area-inset-bottom, 0px) - 104px);
41
+ opacity: 0.6;
42
+ }
43
+ .wm-touch-joystick-knob {
44
+ position: absolute;
45
+ left: 50%;
46
+ top: 50%;
47
+ width: 44px;
48
+ height: 44px;
49
+ margin: -22px 0 0 -22px;
50
+ border-radius: 50%;
51
+ background: rgba(255, 255, 255, 0.65);
52
+ box-shadow: 0 0 12px rgba(255, 255, 255, 0.35);
53
+ pointer-events: none;
54
+ will-change: transform;
55
+ }
56
+ .wm-touch-buttons {
57
+ position: absolute;
58
+ right: calc(env(safe-area-inset-right, 0px) + 20px);
59
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 24px);
60
+ display: flex;
61
+ flex-direction: column;
62
+ align-items: center;
63
+ gap: 12px;
64
+ pointer-events: none;
65
+ }
66
+ .wm-touch-btn {
67
+ pointer-events: auto;
68
+ border-radius: 50%;
69
+ border: 1.5px solid rgba(255, 255, 255, 0.32);
70
+ background: rgba(8, 11, 16, 0.65);
71
+ backdrop-filter: blur(4px);
72
+ color: #fff;
73
+ font-family: inherit;
74
+ font-weight: 600;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ cursor: pointer;
79
+ -webkit-tap-highlight-color: transparent;
80
+ touch-action: none;
81
+ transition: transform .08s ease, background .08s ease;
82
+ padding: 0;
83
+ }
84
+ .wm-touch-btn:active {
85
+ background: rgba(255, 255, 255, 0.25);
86
+ transform: scale(0.92);
87
+ }
88
+ .wm-btn-jump {
89
+ width: 58px;
90
+ height: 58px;
91
+ font-size: 18px;
92
+ }
93
+ .wm-btn-interact {
94
+ width: 48px;
95
+ height: 48px;
96
+ font-size: 14px;
97
+ letter-spacing: .05em;
98
+ }
99
+ .wm-btn-view {
100
+ width: 40px;
101
+ height: 40px;
102
+ font-size: 14px;
103
+ opacity: 0.85;
104
+ }
105
+ .wm-touch-menu-btn {
106
+ position: absolute;
107
+ top: 14px;
108
+ right: 14px;
109
+ width: 40px;
110
+ height: 40px;
111
+ border-radius: 10px;
112
+ border: 1px solid rgba(255, 255, 255, 0.22);
113
+ background: rgba(8, 11, 16, 0.6);
114
+ backdrop-filter: blur(4px);
115
+ color: #fff;
116
+ font-size: 15px;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ cursor: pointer;
121
+ pointer-events: auto;
122
+ touch-action: manipulation;
123
+ -webkit-tap-highlight-color: transparent;
124
+ padding: 0;
125
+ }
126
+ .wm-touch-menu-btn:active {
127
+ background: rgba(255, 255, 255, 0.22);
128
+ }
129
+ `;
130
+
131
+ function injectTouchStyles(): void {
132
+ if (typeof document === 'undefined' || document.getElementById(TOUCH_STYLE_ID)) return;
133
+ const style = document.createElement('style');
134
+ style.id = TOUCH_STYLE_ID;
135
+ style.textContent = TOUCH_CSS;
136
+ document.head.appendChild(style);
137
+ }
138
+
139
+ export interface TouchControlsOptions {
140
+ element?: HTMLElement;
141
+ onExit?: () => void;
142
+ }
143
+
144
+ export class TouchControls {
145
+ private root: HTMLDivElement;
146
+ private joystickBase: HTMLDivElement;
147
+ private joystickKnob: HTMLDivElement;
148
+ private onExit?: () => void;
149
+
150
+ private movePointerId: number | null = null;
151
+ private moveStart = { x: 0, y: 0 };
152
+ private moveAxis = { x: 0, z: 0 };
153
+
154
+ private lookPointerId: number | null = null;
155
+ private lookPrev = { x: 0, y: 0 };
156
+ private lookDelta = { dx: 0, dy: 0 };
157
+
158
+ private actionsDown = new Set<InputAction>();
159
+ private justPressed = new Set<InputAction>();
160
+
161
+ private disposed = false;
162
+ private visible = false;
163
+
164
+ constructor(options: TouchControlsOptions) {
165
+ this.onExit = options.onExit;
166
+
167
+ injectTouchStyles();
168
+
169
+ this.root = document.createElement('div');
170
+ this.root.className = 'wm-touch-root';
171
+ this.root.style.display = 'none';
172
+
173
+ // Joystick UI
174
+ this.joystickBase = document.createElement('div');
175
+ this.joystickBase.className = 'wm-touch-joystick-base wm-idle';
176
+ this.joystickKnob = document.createElement('div');
177
+ this.joystickKnob.className = 'wm-touch-joystick-knob';
178
+ this.joystickBase.appendChild(this.joystickKnob);
179
+ this.root.appendChild(this.joystickBase);
180
+
181
+ // Action buttons (bottom right)
182
+ const buttons = document.createElement('div');
183
+ buttons.className = 'wm-touch-buttons';
184
+
185
+ const viewBtn = this.createActionButton('V', 'toggleView', 'wm-btn-view', 'Camera mode');
186
+ const interactBtn = this.createActionButton('E', 'interact', 'wm-btn-interact', 'Interact');
187
+ const jumpBtn = this.createActionButton('▲', 'jump', 'wm-btn-jump', 'Jump');
188
+
189
+ buttons.append(viewBtn, interactBtn, jumpBtn);
190
+ this.root.appendChild(buttons);
191
+
192
+ // Menu / Pause button (top right)
193
+ const menuBtn = document.createElement('button');
194
+ menuBtn.type = 'button';
195
+ menuBtn.className = 'wm-touch-menu-btn';
196
+ menuBtn.textContent = '⏸';
197
+ menuBtn.title = 'Menu / Pause';
198
+ menuBtn.addEventListener('pointerdown', (e) => {
199
+ e.stopPropagation();
200
+ e.preventDefault();
201
+ this.onExit?.();
202
+ });
203
+ this.root.appendChild(menuBtn);
204
+
205
+ document.body.appendChild(this.root);
206
+
207
+ window.addEventListener('pointerdown', this.handlePointerDown);
208
+ window.addEventListener('pointermove', this.handlePointerMove);
209
+ window.addEventListener('pointerup', this.handlePointerUp);
210
+ window.addEventListener('pointercancel', this.handlePointerUp);
211
+ }
212
+
213
+ private createActionButton(
214
+ label: string,
215
+ action: InputAction,
216
+ className: string,
217
+ title: string,
218
+ ): HTMLButtonElement {
219
+ const btn = document.createElement('button');
220
+ btn.type = 'button';
221
+ btn.className = `wm-touch-btn ${className}`;
222
+ btn.textContent = label;
223
+ btn.title = title;
224
+
225
+ const onDown = (e: PointerEvent) => {
226
+ e.stopPropagation();
227
+ e.preventDefault();
228
+ this.actionsDown.add(action);
229
+ this.justPressed.add(action);
230
+ };
231
+
232
+ const onUp = (e: PointerEvent) => {
233
+ e.stopPropagation();
234
+ e.preventDefault();
235
+ this.actionsDown.delete(action);
236
+ };
237
+
238
+ btn.addEventListener('pointerdown', onDown);
239
+ btn.addEventListener('pointerup', onUp);
240
+ btn.addEventListener('pointercancel', onUp);
241
+
242
+ return btn;
243
+ }
244
+
245
+ private handlePointerDown = (e: PointerEvent): void => {
246
+ if (!this.visible || this.disposed) return;
247
+ if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
248
+
249
+ const target = e.target as HTMLElement | null;
250
+ if (target?.closest('.wm-touch-btn, .wm-touch-menu-btn, .wm-prompt, .wm-badge, .wm-lock')) {
251
+ return;
252
+ }
253
+
254
+ const halfWidth = window.innerWidth * 0.5;
255
+
256
+ // Left half: movement joystick
257
+ if (this.movePointerId === null && e.clientX < halfWidth) {
258
+ this.movePointerId = e.pointerId;
259
+ this.moveAxis = { x: 0, z: 0 };
260
+ // Grab the resting stick where it is; anywhere else, the stick comes to the thumb.
261
+ const rest = this.joystickBase.getBoundingClientRect();
262
+ const restX = rest.left + rest.width / 2;
263
+ const restY = rest.top + rest.height / 2;
264
+ const onStick = Math.hypot(e.clientX - restX, e.clientY - restY) < rest.width * 0.75;
265
+ this.moveStart = onStick ? { x: restX, y: restY } : { x: e.clientX, y: e.clientY };
266
+ this.joystickBase.classList.remove('wm-idle');
267
+ this.joystickBase.style.left = `${this.moveStart.x}px`;
268
+ this.joystickBase.style.top = `${this.moveStart.y}px`;
269
+ this.joystickKnob.style.transform = '';
270
+ if (onStick) this.handlePointerMove(e);
271
+ return;
272
+ }
273
+
274
+ // Right half (or secondary touch): camera look
275
+ if (this.lookPointerId === null && e.clientX >= halfWidth * 0.8) {
276
+ this.lookPointerId = e.pointerId;
277
+ this.lookPrev = { x: e.clientX, y: e.clientY };
278
+ }
279
+ };
280
+
281
+ private handlePointerMove = (e: PointerEvent): void => {
282
+ if (!this.visible || this.disposed) return;
283
+
284
+ if (e.pointerId === this.movePointerId) {
285
+ const dx = e.clientX - this.moveStart.x;
286
+ const dy = e.clientY - this.moveStart.y;
287
+ const dist = Math.hypot(dx, dy);
288
+ const maxRadius = 50;
289
+ const clampedDist = Math.min(dist, maxRadius);
290
+ const angle = Math.atan2(dy, dx);
291
+
292
+ const knobX = Math.cos(angle) * clampedDist;
293
+ const knobY = Math.sin(angle) * clampedDist;
294
+ this.joystickKnob.style.transform = `translate(${knobX}px, ${knobY}px)`;
295
+
296
+ this.moveAxis.x = clampedDist > 0 ? knobX / maxRadius : 0;
297
+ this.moveAxis.z = clampedDist > 0 ? knobY / maxRadius : 0;
298
+ return;
299
+ }
300
+
301
+ if (e.pointerId === this.lookPointerId) {
302
+ const dx = e.clientX - this.lookPrev.x;
303
+ const dy = e.clientY - this.lookPrev.y;
304
+ this.lookPrev = { x: e.clientX, y: e.clientY };
305
+ this.lookDelta.dx += dx * 1.4;
306
+ this.lookDelta.dy += dy * 1.4;
307
+ }
308
+ };
309
+
310
+ private handlePointerUp = (e: PointerEvent): void => {
311
+ if (e.pointerId === this.movePointerId) {
312
+ this.movePointerId = null;
313
+ this.moveAxis = { x: 0, z: 0 };
314
+ this.restJoystick();
315
+ }
316
+ if (e.pointerId === this.lookPointerId) {
317
+ this.lookPointerId = null;
318
+ }
319
+ };
320
+
321
+ getMoveAxis(): { x: number; z: number } {
322
+ return this.moveAxis;
323
+ }
324
+
325
+ readLook(): { dx: number; dy: number } {
326
+ const res = { dx: this.lookDelta.dx, dy: this.lookDelta.dy };
327
+ this.lookDelta = { dx: 0, dy: 0 };
328
+ return res;
329
+ }
330
+
331
+ isDown(action: InputAction): boolean {
332
+ return this.actionsDown.has(action);
333
+ }
334
+
335
+ consume(action: InputAction): boolean {
336
+ if (!this.justPressed.has(action)) return false;
337
+ this.justPressed.delete(action);
338
+ return true;
339
+ }
340
+
341
+ triggerAction(action: InputAction): void {
342
+ this.justPressed.add(action);
343
+ }
344
+
345
+ setVisible(visible: boolean): void {
346
+ this.visible = visible;
347
+ this.root.style.display = visible ? 'block' : 'none';
348
+ if (!visible) {
349
+ this.reset();
350
+ }
351
+ }
352
+
353
+ reset(): void {
354
+ this.movePointerId = null;
355
+ this.lookPointerId = null;
356
+ this.moveAxis = { x: 0, z: 0 };
357
+ this.lookDelta = { dx: 0, dy: 0 };
358
+ this.actionsDown.clear();
359
+ this.justPressed.clear();
360
+ this.restJoystick();
361
+ }
362
+
363
+ private restJoystick(): void {
364
+ this.joystickBase.classList.add('wm-idle');
365
+ this.joystickBase.style.left = '';
366
+ this.joystickBase.style.top = '';
367
+ this.joystickKnob.style.transform = '';
368
+ }
369
+
370
+ dispose(): void {
371
+ this.disposed = true;
372
+ window.removeEventListener('pointerdown', this.handlePointerDown);
373
+ window.removeEventListener('pointermove', this.handlePointerMove);
374
+ window.removeEventListener('pointerup', this.handlePointerUp);
375
+ window.removeEventListener('pointercancel', this.handlePointerUp);
376
+ this.root.remove();
377
+ }
378
+ }
@@ -0,0 +1,31 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ type AnyListener = (payload: any) => void;
3
+
4
+ /** Tiny typed event bus. Deliberately no dependencies. */
5
+ export class Emitter<Events extends object> {
6
+ private listeners = new Map<keyof Events, Set<AnyListener>>();
7
+
8
+ on<K extends keyof Events>(event: K, fn: (payload: Events[K]) => void): () => void {
9
+ let set = this.listeners.get(event);
10
+ if (!set) {
11
+ set = new Set();
12
+ this.listeners.set(event, set);
13
+ }
14
+ set.add(fn);
15
+ return () => this.off(event, fn);
16
+ }
17
+
18
+ off<K extends keyof Events>(event: K, fn: (payload: Events[K]) => void): void {
19
+ this.listeners.get(event)?.delete(fn);
20
+ }
21
+
22
+ emit<K extends keyof Events>(event: K, payload: Events[K]): void {
23
+ const set = this.listeners.get(event);
24
+ if (!set) return;
25
+ for (const fn of [...set]) fn(payload);
26
+ }
27
+
28
+ clear(): void {
29
+ this.listeners.clear();
30
+ }
31
+ }