@crowdedkingdoms/construct 0.0.0 → 0.2.0-dev.1
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/README.md +76 -1
- package/node/copy-dsh-web.mjs +71 -0
- package/node/security-headers.d.mts +23 -0
- package/node/security-headers.mjs +243 -0
- package/node/vite-plugins.d.mts +6 -0
- package/node/vite-plugins.mjs +102 -0
- package/package.json +46 -3
- package/sandbox/boot.ts +74 -0
- package/src/engine/GameLoop.ts +106 -0
- package/src/engine/GameScene.ts +74 -0
- package/src/engine/Input.test.ts +129 -0
- package/src/engine/Input.ts +257 -0
- package/src/engine/SceneRouter.test.ts +80 -0
- package/src/engine/SceneRouter.ts +113 -0
- package/src/engine/controls.test.ts +26 -0
- package/src/engine/controls.ts +63 -0
- package/src/grid/GridProgramRunner.ts +205 -0
- package/src/grid/sandboxProtocol.ts +9 -0
- package/src/index.ts +88 -0
- package/src/platform/GameSession.ts +239 -0
- package/src/platform/auth/AuthService.ts +45 -0
- package/src/platform/config.test.ts +110 -0
- package/src/platform/config.ts +212 -0
- package/src/platform/envScope.test.ts +42 -0
- package/src/platform/envScope.ts +76 -0
- package/src/platform/exec/worldHub.test.ts +61 -0
- package/src/platform/exec/worldHub.ts +86 -0
- package/src/platform/media/VoiceService.ts +423 -0
- package/src/platform/media/WebcamService.ts +346 -0
- package/src/platform/media/mulaw.test.ts +20 -0
- package/src/platform/media/mulaw.ts +56 -0
- package/src/platform/media/videoFrames.test.ts +197 -0
- package/src/platform/media/voiceFrames.test.ts +82 -0
- package/src/platform/model/ModelService.test.ts +59 -0
- package/src/platform/model/ModelService.ts +124 -0
- package/src/platform/network/NetworkManager.ts +519 -0
- package/src/platform/onboarding/steps.d.mts +154 -0
- package/src/platform/onboarding/steps.mjs +616 -0
- package/src/platform/onboarding/steps.test.ts +486 -0
- package/src/platform/realtime/WorldStores.ts +153 -0
- package/src/platform/realtime/actorCodec.test.ts +62 -0
- package/src/platform/realtime/actorCodec.ts +142 -0
- package/src/platform/realtime/space.test.ts +33 -0
- package/src/platform/realtime/space.ts +63 -0
- package/src/platform/social/ChatService.ts +92 -0
- package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
- package/src/platform/studio/ClientModLifecycle.ts +102 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
- package/src/platform/studio/GridService.test.ts +131 -0
- package/src/platform/studio/GridService.ts +295 -0
- package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
- package/src/platform/studio/HumanInputMonitor.ts +48 -0
- package/src/platform/studio/StudioService.ts +686 -0
- package/src/platform/studio/agentLocomotion.test.ts +31 -0
- package/src/platform/studio/agentLocomotion.ts +97 -0
- package/src/platform/studio/clientModHost.test.ts +199 -0
- package/src/platform/studio/clientModHost.ts +369 -0
- package/src/platform/studio/instanceSchema.test.ts +280 -0
- package/src/platform/studio/instanceSchema.ts +746 -0
- package/src/platform/studio/instanceStore.test.ts +97 -0
- package/src/platform/studio/instanceStore.ts +120 -0
- package/src/platform/studio/modOverlay.test.ts +56 -0
- package/src/platform/studio/modOverlay.ts +51 -0
- package/src/platform/studio/permissions.test.ts +51 -0
- package/src/platform/studio/permissions.ts +134 -0
- package/src/platform/studio/pointerClicks.test.ts +23 -0
- package/src/platform/studio/pointerClicks.ts +132 -0
- package/src/platform/util/Emitter.ts +32 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame loop, and the one place where "the scene's local pose" becomes
|
|
3
|
+
* "what everyone else sees". Each frame:
|
|
4
|
+
*
|
|
5
|
+
* 1. the active scene updates and renders,
|
|
6
|
+
* 2. the loop reads `scene.localPose()`, stamps program/name/tint, and hands
|
|
7
|
+
* it to the replication store (which sends at 5 Hz, only on change),
|
|
8
|
+
* 3. if the player crossed into another chunk, the store is told to move,
|
|
9
|
+
* 4. the save blob's position is patched (autosaved by the store).
|
|
10
|
+
*
|
|
11
|
+
* Scenes never talk to the network for presence. That is the whole point.
|
|
12
|
+
*/
|
|
13
|
+
import type { SceneSize } from './GameScene';
|
|
14
|
+
import type { SceneRouter } from './SceneRouter';
|
|
15
|
+
import type { GameSession } from '../platform/GameSession';
|
|
16
|
+
import { FLAG_IN_PROGRAM, FLAG_MOVING } from '../platform/realtime/actorCodec';
|
|
17
|
+
import { chunkKey, worldToChunk } from '../platform/realtime/space';
|
|
18
|
+
|
|
19
|
+
export class GameLoop {
|
|
20
|
+
private frame: number | null = null;
|
|
21
|
+
private lastMs = 0;
|
|
22
|
+
private lastChunkKey: string | null = null;
|
|
23
|
+
private lastSavePatchMs = 0;
|
|
24
|
+
private rightInset = 0;
|
|
25
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly root: HTMLElement,
|
|
29
|
+
private readonly router: SceneRouter,
|
|
30
|
+
private readonly session: GameSession,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
start(): void {
|
|
34
|
+
if (this.frame !== null) return;
|
|
35
|
+
this.resizeObserver = new ResizeObserver(() => this.router.resize(this.size()));
|
|
36
|
+
this.resizeObserver.observe(this.root);
|
|
37
|
+
this.router.resize(this.size());
|
|
38
|
+
this.lastMs = performance.now();
|
|
39
|
+
const tick = (nowMs: number) => {
|
|
40
|
+
this.frame = requestAnimationFrame(tick);
|
|
41
|
+
const dt = Math.min(0.1, Math.max(0, (nowMs - this.lastMs) / 1000));
|
|
42
|
+
this.lastMs = nowMs;
|
|
43
|
+
this.step(dt, nowMs);
|
|
44
|
+
};
|
|
45
|
+
this.frame = requestAnimationFrame(tick);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
stop(): void {
|
|
49
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame);
|
|
50
|
+
this.frame = null;
|
|
51
|
+
this.resizeObserver?.disconnect();
|
|
52
|
+
this.resizeObserver = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A docked overlay took `px` pixels on the right; re-measure. */
|
|
56
|
+
setRightInset(px: number): void {
|
|
57
|
+
this.rightInset = px;
|
|
58
|
+
this.router.resize(this.size());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
size(): SceneSize {
|
|
62
|
+
// The stylesheet already shrinks #game-root by --ck-game-right-inset while
|
|
63
|
+
// Crowdy Studio is docked, so the rect is the visible area; the inset is
|
|
64
|
+
// passed along for scenes that want to know, not subtracted again.
|
|
65
|
+
const rect = this.root.getBoundingClientRect();
|
|
66
|
+
return {
|
|
67
|
+
width: Math.max(1, Math.floor(rect.width)),
|
|
68
|
+
height: Math.max(1, Math.floor(rect.height)),
|
|
69
|
+
rightInset: this.rightInset,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private step(dt: number, nowMs: number): void {
|
|
74
|
+
const scene = this.router.current;
|
|
75
|
+
if (!scene) return;
|
|
76
|
+
scene.update(dt, nowMs);
|
|
77
|
+
if (!this.session.joined) return;
|
|
78
|
+
|
|
79
|
+
const pose = scene.localPose();
|
|
80
|
+
const moving = Math.hypot(pose.vx, pose.vy, pose.vz) > 0.05;
|
|
81
|
+
const flags =
|
|
82
|
+
(pose.flags & ~(FLAG_MOVING | FLAG_IN_PROGRAM)) |
|
|
83
|
+
(moving ? FLAG_MOVING : 0) |
|
|
84
|
+
(scene.programId !== 0 ? FLAG_IN_PROGRAM : 0) |
|
|
85
|
+
this.session.extraPoseFlags();
|
|
86
|
+
this.session.feedPose({
|
|
87
|
+
...pose,
|
|
88
|
+
flags,
|
|
89
|
+
program: scene.programId,
|
|
90
|
+
name: this.session.displayName,
|
|
91
|
+
tint: this.session.tint,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const chunk = worldToChunk(pose);
|
|
95
|
+
const key = chunkKey(chunk);
|
|
96
|
+
if (key !== this.lastChunkKey) {
|
|
97
|
+
this.lastChunkKey = key;
|
|
98
|
+
void this.session.moveTo(chunk);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (nowMs - this.lastSavePatchMs > 2000) {
|
|
102
|
+
this.lastSavePatchMs = nowMs;
|
|
103
|
+
this.session.rememberPosition(scene.id, pose);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderer adapter boundary.
|
|
3
|
+
*
|
|
4
|
+
* A scene owns a renderer (three.js, pixi.js, a 2D canvas, anything) and the
|
|
5
|
+
* local player's position inside it. It does NOT own networking, identity,
|
|
6
|
+
* persistence or Studio: those come in through `SceneContext` and are the same
|
|
7
|
+
* object for every scene, which is what lets the holodeck (three.js) hand a
|
|
8
|
+
* player to a program (pixi.js) without anyone reconnecting.
|
|
9
|
+
*
|
|
10
|
+
* The contract is deliberately small. If your game needs more from the
|
|
11
|
+
* platform, add it to `GameSession` (engine-agnostic) rather than widening
|
|
12
|
+
* this interface, and read docs/RENDERER-ADAPTER.md before you do.
|
|
13
|
+
*/
|
|
14
|
+
import type { GameSession } from '../platform/GameSession';
|
|
15
|
+
import type { Pose } from '../platform/realtime/actorCodec';
|
|
16
|
+
import type { Input } from './Input';
|
|
17
|
+
import type { SceneRouter } from './SceneRouter';
|
|
18
|
+
|
|
19
|
+
export interface SceneContext {
|
|
20
|
+
/** The element the scene renders into. Sized by the loop on resize. */
|
|
21
|
+
root: HTMLElement;
|
|
22
|
+
/** Everything platform: session, remote players, chat, save, studio. */
|
|
23
|
+
session: GameSession;
|
|
24
|
+
/** Keyboard/pointer state with a suppression switch for overlays. */
|
|
25
|
+
input: Input;
|
|
26
|
+
/** Ask to load another scene (a program pad, a "return to hub" key). */
|
|
27
|
+
router: SceneRouter;
|
|
28
|
+
/** The DOM HUD: transient hints and toasts. Scenes draw no DOM themselves. */
|
|
29
|
+
hud: SceneHud;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SceneHud {
|
|
33
|
+
/** A persistent one-line prompt ("Press E to load Paint"); null clears it. */
|
|
34
|
+
setHint(text: string | null): void;
|
|
35
|
+
/** A short-lived notice. */
|
|
36
|
+
toast(text: string, tone?: 'info' | 'warn' | 'error'): void;
|
|
37
|
+
/** Replace the F1 / ? overlay lines for this scene. */
|
|
38
|
+
setHelpLines?(lines: string[]): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SceneSize {
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
/** Pixels reserved on the right by a docked overlay (Crowdy Studio). */
|
|
45
|
+
rightInset: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GameScene {
|
|
49
|
+
/** Stable id used by the router and the save blob. */
|
|
50
|
+
readonly id: string;
|
|
51
|
+
/**
|
|
52
|
+
* The `program` byte broadcast in the pose while the player is here
|
|
53
|
+
* (0 = holodeck). Scenes hide actors whose `program` differs.
|
|
54
|
+
*/
|
|
55
|
+
readonly programId: number;
|
|
56
|
+
|
|
57
|
+
mount(context: SceneContext, size: SceneSize): Promise<void> | void;
|
|
58
|
+
/** Called once per animation frame with seconds elapsed. Simulate + render. */
|
|
59
|
+
update(dt: number, nowMs: number): void;
|
|
60
|
+
resize(size: SceneSize): void;
|
|
61
|
+
unmount(): void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The local player's current pose in this scene's coordinates. The loop
|
|
65
|
+
* feeds it to the replication store every frame (the store dedups sends).
|
|
66
|
+
* A scene with no notion of position returns a fixed pose.
|
|
67
|
+
*/
|
|
68
|
+
localPose(): Pose;
|
|
69
|
+
/** Place the local player (spawn, restore from save, teleport). */
|
|
70
|
+
setLocalPosition(position: { x: number; y: number; z: number; yaw?: number }): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A scene factory registered with the router; constructed on first load. */
|
|
74
|
+
export type SceneFactory = () => GameScene;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { Input, isPointerLockOn, isTextEntry } from './Input';
|
|
4
|
+
|
|
5
|
+
describe('isPointerLockOn', () => {
|
|
6
|
+
it('treats a descendant canvas as locked, not only the root', () => {
|
|
7
|
+
const root = document.createElement('div');
|
|
8
|
+
const canvas = document.createElement('canvas');
|
|
9
|
+
root.appendChild(canvas);
|
|
10
|
+
expect(isPointerLockOn(root, canvas)).toBe(true);
|
|
11
|
+
expect(isPointerLockOn(root, root)).toBe(true);
|
|
12
|
+
expect(isPointerLockOn(root, document.createElement('div'))).toBe(false);
|
|
13
|
+
expect(isPointerLockOn(root, null)).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('isTextEntry', () => {
|
|
18
|
+
it('recognises inputs and Monaco', () => {
|
|
19
|
+
const input = document.createElement('input');
|
|
20
|
+
expect(isTextEntry(input)).toBe(true);
|
|
21
|
+
const div = document.createElement('div');
|
|
22
|
+
expect(isTextEntry(div)).toBe(false);
|
|
23
|
+
const monaco = document.createElement('div');
|
|
24
|
+
monaco.className = 'monaco-editor';
|
|
25
|
+
const inner = document.createElement('div');
|
|
26
|
+
monaco.appendChild(inner);
|
|
27
|
+
expect(isTextEntry(inner)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('Input', () => {
|
|
32
|
+
const inputs: Input[] = [];
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const input of inputs) input.dispose();
|
|
36
|
+
inputs.length = 0;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function attach(): { input: Input; root: HTMLElement; canvas: HTMLCanvasElement } {
|
|
40
|
+
const root = document.createElement('div');
|
|
41
|
+
const canvas = document.createElement('canvas');
|
|
42
|
+
root.appendChild(canvas);
|
|
43
|
+
document.body.appendChild(root);
|
|
44
|
+
const input = new Input();
|
|
45
|
+
input.attach(root);
|
|
46
|
+
inputs.push(input);
|
|
47
|
+
return { input, root, canvas };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it('maps WASD to axes and Shift to run', () => {
|
|
51
|
+
const { input } = attach();
|
|
52
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' }));
|
|
53
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyD' }));
|
|
54
|
+
expect(input.axes()).toEqual({ x: 1, y: 1 });
|
|
55
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'ShiftLeft' }));
|
|
56
|
+
expect(input.isRun()).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not record gameplay keys while suppressed or in a text field', () => {
|
|
60
|
+
const { input } = attach();
|
|
61
|
+
const release = input.suppress();
|
|
62
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' }));
|
|
63
|
+
expect(input.axes()).toEqual({ x: 0, y: 0 });
|
|
64
|
+
release();
|
|
65
|
+
const field = document.createElement('input');
|
|
66
|
+
document.body.appendChild(field);
|
|
67
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW', bubbles: true }));
|
|
68
|
+
// default target is window/body, not the field — still records
|
|
69
|
+
expect(input.isDown('KeyW')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('sets locked when pointer lock is on a descendant canvas', () => {
|
|
73
|
+
const { input, canvas } = attach();
|
|
74
|
+
Object.defineProperty(document, 'pointerLockElement', {
|
|
75
|
+
configurable: true,
|
|
76
|
+
get: () => canvas,
|
|
77
|
+
});
|
|
78
|
+
document.dispatchEvent(new Event('pointerlockchange'));
|
|
79
|
+
expect(input.pointer.locked).toBe(true);
|
|
80
|
+
expect(input.isLooking()).toBe(true);
|
|
81
|
+
Object.defineProperty(document, 'pointerLockElement', {
|
|
82
|
+
configurable: true,
|
|
83
|
+
get: () => null,
|
|
84
|
+
});
|
|
85
|
+
document.dispatchEvent(new Event('pointerlockchange'));
|
|
86
|
+
expect(input.pointer.locked).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('looks while RMB is held and cancels on exitLook', () => {
|
|
90
|
+
const { input } = attach();
|
|
91
|
+
window.dispatchEvent(new PointerEvent('pointerdown', { button: 2, buttons: 2 }));
|
|
92
|
+
expect(input.isLooking()).toBe(true);
|
|
93
|
+
input.exitLook();
|
|
94
|
+
expect(input.isLooking()).toBe(false);
|
|
95
|
+
window.dispatchEvent(new PointerEvent('pointerup', { button: 2, buttons: 0 }));
|
|
96
|
+
window.dispatchEvent(new PointerEvent('pointerdown', { button: 2, buttons: 2 }));
|
|
97
|
+
expect(input.isLooking()).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('pans on MMB or Alt+LMB', () => {
|
|
101
|
+
const { input } = attach();
|
|
102
|
+
window.dispatchEvent(new PointerEvent('pointerdown', { button: 1, buttons: 4 }));
|
|
103
|
+
expect(input.isPanning()).toBe(true);
|
|
104
|
+
window.dispatchEvent(new PointerEvent('pointerup', { button: 1, buttons: 0 }));
|
|
105
|
+
window.dispatchEvent(new PointerEvent('pointerdown', { button: 0, buttons: 1, altKey: true }));
|
|
106
|
+
expect(input.isPanning()).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('accumulates wheel over the game root and takeWheel consumes it', () => {
|
|
110
|
+
const { input, canvas } = attach();
|
|
111
|
+
canvas.dispatchEvent(
|
|
112
|
+
new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }),
|
|
113
|
+
);
|
|
114
|
+
expect(input.takeWheel()).toBe(-120);
|
|
115
|
+
expect(input.takeWheel()).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('onKeys registers every code', () => {
|
|
119
|
+
const { input } = attach();
|
|
120
|
+
const seen: string[] = [];
|
|
121
|
+
const off = input.onKeys(['KeyT', 'Enter'], (event) => seen.push(event.code));
|
|
122
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyT' }));
|
|
123
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Enter' }));
|
|
124
|
+
expect(seen).toEqual(['KeyT', 'Enter']);
|
|
125
|
+
off();
|
|
126
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyT' }));
|
|
127
|
+
expect(seen).toEqual(['KeyT', 'Enter']);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard + pointer state shared by every scene, with one switch overlays
|
|
3
|
+
* flip: `suppressed`. While an overlay (Crowdy Studio, the chat box, the Setup
|
|
4
|
+
* wizard) has focus, gameplay must not see keys — but the overlay's own
|
|
5
|
+
* shortcuts still fire through `onKey`.
|
|
6
|
+
*
|
|
7
|
+
* Pointer lock is true when the lock element is the attached root *or any
|
|
8
|
+
* descendant* (the scene canvas). Look also works as an RMB drag so insecure
|
|
9
|
+
* HTTP (the IDE browser on a public IP) can still turn the camera.
|
|
10
|
+
*/
|
|
11
|
+
import { Controls } from './controls';
|
|
12
|
+
|
|
13
|
+
export type KeyHandler = (event: KeyboardEvent) => void;
|
|
14
|
+
|
|
15
|
+
export interface PointerState {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
/** Movement since the last frame (consumed by `takePointerDelta`). */
|
|
19
|
+
dx: number;
|
|
20
|
+
dy: number;
|
|
21
|
+
down: boolean;
|
|
22
|
+
locked: boolean;
|
|
23
|
+
/** `MouseEvent.buttons` bitfield (1 LMB, 2 RMB, 4 MMB). */
|
|
24
|
+
buttons: number;
|
|
25
|
+
alt: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TEXT_INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
29
|
+
|
|
30
|
+
export function isTextEntry(target: EventTarget | null): boolean {
|
|
31
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
32
|
+
if (TEXT_INPUT_TAGS.has(target.tagName)) return true;
|
|
33
|
+
if (target.isContentEditable) return true;
|
|
34
|
+
// Monaco's editor surface.
|
|
35
|
+
return Boolean(target.closest('.monaco-editor'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when pointer lock is on `root` or a node inside it (the scene canvas). */
|
|
39
|
+
export function isPointerLockOn(root: EventTarget | null, lockElement: Element | null): boolean {
|
|
40
|
+
if (!lockElement || !(root instanceof Node)) return false;
|
|
41
|
+
return lockElement === root || root.contains(lockElement);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Input {
|
|
45
|
+
private readonly keys = new Set<string>();
|
|
46
|
+
private readonly keyHandlers = new Map<string, Set<KeyHandler>>();
|
|
47
|
+
private readonly pointerState: PointerState = {
|
|
48
|
+
x: 0,
|
|
49
|
+
y: 0,
|
|
50
|
+
dx: 0,
|
|
51
|
+
dy: 0,
|
|
52
|
+
down: false,
|
|
53
|
+
locked: false,
|
|
54
|
+
buttons: 0,
|
|
55
|
+
alt: false,
|
|
56
|
+
};
|
|
57
|
+
private suppressedDepth = 0;
|
|
58
|
+
private detach: (() => void) | null = null;
|
|
59
|
+
private attachedRoot: HTMLElement | null = null;
|
|
60
|
+
private wheelY = 0;
|
|
61
|
+
/** Escape cancels RMB look until the button is released. */
|
|
62
|
+
private lookCancelled = false;
|
|
63
|
+
|
|
64
|
+
attach(target: HTMLElement): void {
|
|
65
|
+
if (this.detach) return;
|
|
66
|
+
this.attachedRoot = target;
|
|
67
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
68
|
+
const inText = isTextEntry(event.target);
|
|
69
|
+
// Shortcuts registered with onKey fire even while suppressed, unless the
|
|
70
|
+
// player is typing in a text field.
|
|
71
|
+
if (!inText) {
|
|
72
|
+
const handlers = this.keyHandlers.get(event.code);
|
|
73
|
+
if (handlers) for (const handler of [...handlers]) handler(event);
|
|
74
|
+
}
|
|
75
|
+
if (this.suppressed || inText) return;
|
|
76
|
+
this.keys.add(event.code);
|
|
77
|
+
};
|
|
78
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
79
|
+
this.keys.delete(event.code);
|
|
80
|
+
};
|
|
81
|
+
const onBlur = () => this.keys.clear();
|
|
82
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
83
|
+
if (this.suppressed) return;
|
|
84
|
+
this.pointerState.dx += event.movementX;
|
|
85
|
+
this.pointerState.dy += event.movementY;
|
|
86
|
+
this.pointerState.x = event.clientX;
|
|
87
|
+
this.pointerState.y = event.clientY;
|
|
88
|
+
this.pointerState.buttons = event.buttons;
|
|
89
|
+
this.pointerState.alt = event.altKey;
|
|
90
|
+
};
|
|
91
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
92
|
+
if (this.suppressed || isTextEntry(event.target)) return;
|
|
93
|
+
this.pointerState.down = true;
|
|
94
|
+
this.pointerState.buttons = event.buttons;
|
|
95
|
+
this.pointerState.alt = event.altKey;
|
|
96
|
+
this.pointerState.x = event.clientX;
|
|
97
|
+
this.pointerState.y = event.clientY;
|
|
98
|
+
if (event.button === 2) this.lookCancelled = false;
|
|
99
|
+
};
|
|
100
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
101
|
+
this.pointerState.buttons = event.buttons;
|
|
102
|
+
this.pointerState.alt = event.altKey;
|
|
103
|
+
if (event.buttons === 0) this.pointerState.down = false;
|
|
104
|
+
if ((event.buttons & 2) === 0) this.lookCancelled = false;
|
|
105
|
+
};
|
|
106
|
+
const onLockChange = () => {
|
|
107
|
+
this.pointerState.locked = isPointerLockOn(target, document.pointerLockElement);
|
|
108
|
+
};
|
|
109
|
+
const onWheel = (event: WheelEvent) => {
|
|
110
|
+
if (this.suppressed || isTextEntry(event.target)) return;
|
|
111
|
+
if (
|
|
112
|
+
this.attachedRoot &&
|
|
113
|
+
event.target instanceof Node &&
|
|
114
|
+
!this.attachedRoot.contains(event.target)
|
|
115
|
+
) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.wheelY += event.deltaY;
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
};
|
|
121
|
+
const onContextMenu = (event: Event) => {
|
|
122
|
+
if (this.isLooking() || (this.pointerState.buttons & 2) !== 0) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
window.addEventListener('keydown', onKeyDown);
|
|
127
|
+
window.addEventListener('keyup', onKeyUp);
|
|
128
|
+
window.addEventListener('blur', onBlur);
|
|
129
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
130
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
131
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
132
|
+
window.addEventListener('wheel', onWheel, { passive: false });
|
|
133
|
+
window.addEventListener('contextmenu', onContextMenu);
|
|
134
|
+
document.addEventListener('pointerlockchange', onLockChange);
|
|
135
|
+
this.detach = () => {
|
|
136
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
137
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
138
|
+
window.removeEventListener('blur', onBlur);
|
|
139
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
140
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
141
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
142
|
+
window.removeEventListener('wheel', onWheel);
|
|
143
|
+
window.removeEventListener('contextmenu', onContextMenu);
|
|
144
|
+
document.removeEventListener('pointerlockchange', onLockChange);
|
|
145
|
+
this.detach = null;
|
|
146
|
+
this.attachedRoot = null;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
dispose(): void {
|
|
151
|
+
this.detach?.();
|
|
152
|
+
this.keys.clear();
|
|
153
|
+
this.keyHandlers.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** True while an overlay owns the keyboard. Nested overlays stack. */
|
|
157
|
+
get suppressed(): boolean {
|
|
158
|
+
return this.suppressedDepth > 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Suppress gameplay input; returns the release function. */
|
|
162
|
+
suppress(): () => void {
|
|
163
|
+
this.suppressedDepth += 1;
|
|
164
|
+
this.keys.clear();
|
|
165
|
+
let released = false;
|
|
166
|
+
return () => {
|
|
167
|
+
if (released) return;
|
|
168
|
+
released = true;
|
|
169
|
+
this.suppressedDepth = Math.max(0, this.suppressedDepth - 1);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isDown(code: string): boolean {
|
|
174
|
+
return this.keys.has(code);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
anyDown(codes: readonly string[]): boolean {
|
|
178
|
+
return codes.some((code) => this.keys.has(code));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** -1..1 on each axis from WASD / arrows. */
|
|
182
|
+
axes(): { x: number; y: number } {
|
|
183
|
+
const x =
|
|
184
|
+
(this.anyDown(Controls.moveRight) ? 1 : 0) - (this.anyDown(Controls.moveLeft) ? 1 : 0);
|
|
185
|
+
const y =
|
|
186
|
+
(this.anyDown(Controls.moveForward) ? 1 : 0) - (this.anyDown(Controls.moveBack) ? 1 : 0);
|
|
187
|
+
return { x, y };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
isRun(): boolean {
|
|
191
|
+
return this.anyDown(Controls.run);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pointer-lock look, or RMB held (and not cancelled by Escape). Used by the
|
|
196
|
+
* holodeck so insecure HTTP can still turn the camera.
|
|
197
|
+
*/
|
|
198
|
+
isLooking(): boolean {
|
|
199
|
+
if (this.suppressed) return false;
|
|
200
|
+
if (this.pointerState.locked) return true;
|
|
201
|
+
if (this.lookCancelled) return false;
|
|
202
|
+
return (this.pointerState.buttons & 2) !== 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** MMB, or Alt+LMB — Paint camera pan without stealing paint/erase. */
|
|
206
|
+
isPanning(): boolean {
|
|
207
|
+
if (this.suppressed) return false;
|
|
208
|
+
if ((this.pointerState.buttons & 4) !== 0) return true;
|
|
209
|
+
return this.pointerState.alt && (this.pointerState.buttons & 1) !== 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Exit pointer lock and cancel an in-progress RMB look. */
|
|
213
|
+
exitLook(): void {
|
|
214
|
+
this.lookCancelled = true;
|
|
215
|
+
if (typeof document !== 'undefined' && document.pointerLockElement) {
|
|
216
|
+
document.exitPointerLock();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
onKey(code: string, handler: KeyHandler): () => void {
|
|
221
|
+
let set = this.keyHandlers.get(code);
|
|
222
|
+
if (!set) {
|
|
223
|
+
set = new Set();
|
|
224
|
+
this.keyHandlers.set(code, set);
|
|
225
|
+
}
|
|
226
|
+
set.add(handler);
|
|
227
|
+
return () => {
|
|
228
|
+
set?.delete(handler);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
onKeys(codes: readonly string[], handler: KeyHandler): () => void {
|
|
233
|
+
const releases = codes.map((code) => this.onKey(code, handler));
|
|
234
|
+
return () => {
|
|
235
|
+
for (const release of releases) release();
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get pointer(): Readonly<PointerState> {
|
|
240
|
+
return this.pointerState;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Read and reset the accumulated pointer delta (once per frame). */
|
|
244
|
+
takePointerDelta(): { dx: number; dy: number } {
|
|
245
|
+
const delta = { dx: this.pointerState.dx, dy: this.pointerState.dy };
|
|
246
|
+
this.pointerState.dx = 0;
|
|
247
|
+
this.pointerState.dy = 0;
|
|
248
|
+
return delta;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Read and reset accumulated wheel deltaY (once per frame). */
|
|
252
|
+
takeWheel(): number {
|
|
253
|
+
const y = this.wheelY;
|
|
254
|
+
this.wheelY = 0;
|
|
255
|
+
return y;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { GameScene, SceneContext, SceneSize } from './GameScene';
|
|
4
|
+
import { SceneRouter } from './SceneRouter';
|
|
5
|
+
import { NEUTRAL_POSE } from '../platform/realtime/actorCodec';
|
|
6
|
+
|
|
7
|
+
function fakeScene(
|
|
8
|
+
id: string,
|
|
9
|
+
programId: number,
|
|
10
|
+
failMount = false,
|
|
11
|
+
): GameScene & { mounted: number; unmounted: number } {
|
|
12
|
+
let pos = { x: 0, y: 0, z: 0 };
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
programId,
|
|
16
|
+
mounted: 0,
|
|
17
|
+
unmounted: 0,
|
|
18
|
+
mount() {
|
|
19
|
+
if (failMount) throw new Error(`${id} cannot mount`);
|
|
20
|
+
this.mounted++;
|
|
21
|
+
},
|
|
22
|
+
unmount() {
|
|
23
|
+
this.unmounted++;
|
|
24
|
+
},
|
|
25
|
+
update() {},
|
|
26
|
+
resize() {},
|
|
27
|
+
localPose: () => ({ ...NEUTRAL_POSE, ...pos }),
|
|
28
|
+
setLocalPosition(p) {
|
|
29
|
+
pos = { x: p.x, y: p.y, z: p.z };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const size: SceneSize = { width: 100, height: 50, rightInset: 0 };
|
|
35
|
+
const context = {} as SceneContext;
|
|
36
|
+
|
|
37
|
+
describe('SceneRouter', () => {
|
|
38
|
+
it('constructs scenes lazily, mounts once per load, and unmounts the previous', async () => {
|
|
39
|
+
const router = new SceneRouter();
|
|
40
|
+
const hub = fakeScene('hub', 0);
|
|
41
|
+
const prog = fakeScene('prog', 1);
|
|
42
|
+
const hubFactory = vi.fn(() => hub);
|
|
43
|
+
router.register('hub', hubFactory);
|
|
44
|
+
router.register('prog', () => prog);
|
|
45
|
+
router.bind(context, size);
|
|
46
|
+
expect(hubFactory).not.toHaveBeenCalled();
|
|
47
|
+
await router.load('hub');
|
|
48
|
+
await router.load('prog', { x: 3, y: 0, z: 4 });
|
|
49
|
+
expect(hub.mounted).toBe(1);
|
|
50
|
+
expect(hub.unmounted).toBe(1);
|
|
51
|
+
expect(prog.localPose()).toMatchObject({ x: 3, z: 4 });
|
|
52
|
+
expect(router.current?.id).toBe('prog');
|
|
53
|
+
await router.load('prog');
|
|
54
|
+
expect(prog.mounted).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('restores the previous scene when a mount fails', async () => {
|
|
58
|
+
const router = new SceneRouter();
|
|
59
|
+
const hub = fakeScene('hub', 0);
|
|
60
|
+
const broken = fakeScene('broken', 2, true);
|
|
61
|
+
router.register('hub', () => hub);
|
|
62
|
+
router.register('broken', () => broken);
|
|
63
|
+
router.bind(context, size);
|
|
64
|
+
await router.load('hub', { x: 7, y: 0, z: 7 });
|
|
65
|
+
const errors: unknown[] = [];
|
|
66
|
+
router.events.on('error', (e) => errors.push(e));
|
|
67
|
+
await expect(router.load('broken')).rejects.toThrow(/cannot mount/);
|
|
68
|
+
expect(router.current?.id).toBe('hub');
|
|
69
|
+
expect(hub.mounted).toBe(2);
|
|
70
|
+
expect(hub.localPose()).toMatchObject({ x: 7, z: 7 });
|
|
71
|
+
expect(errors).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses unknown ids and loads before bind', async () => {
|
|
75
|
+
const router = new SceneRouter();
|
|
76
|
+
await expect(router.load('nope')).rejects.toThrow(/bind/);
|
|
77
|
+
router.bind(context, size);
|
|
78
|
+
await expect(router.load('nope')).rejects.toThrow(/No scene registered/);
|
|
79
|
+
});
|
|
80
|
+
});
|