@crowdedkingdoms/construct 0.0.0 → 0.2.1-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 +48 -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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which grid is the player standing on, and what may they do there?
|
|
3
|
+
*
|
|
4
|
+
* Grids are the platform's unit of player authority: a claimed chunk becomes
|
|
5
|
+
* a one-chunk grid the player owns, and the access-tier code keys are
|
|
6
|
+
* materialised onto it. Crowdy Studio opens on a grid, never on bare world.
|
|
7
|
+
*
|
|
8
|
+
* Three sources, in order of authority:
|
|
9
|
+
* 1. `claimHere` -> `marketplace.claimGridChunk` (SELF_CLAIM policy): the
|
|
10
|
+
* new grid, its bounds and the caller's EFFECTIVE permission keys. The
|
|
11
|
+
* result is remembered per browser and recorded in the world hub.
|
|
12
|
+
* 2. The world hub's claim registry (ck-exec, `record_claim` / `claims` /
|
|
13
|
+
* `release_claim`): the player-readable record of who claimed which chunk.
|
|
14
|
+
* The platform's own grid tables answer "which grid contains this chunk"
|
|
15
|
+
* only to app admins (`nearbyGridPermissions` requires manage_apps —
|
|
16
|
+
* measured 2026-09-07), so visitors learn the grid under their feet from
|
|
17
|
+
* here. The hub records a claim for the calling player and lets only that
|
|
18
|
+
* player release it.
|
|
19
|
+
* 3. `gameApps.nearbyPermissions`, when the caller is an admin, to refresh
|
|
20
|
+
* the effective keys on an owned grid. Failing that is expected for
|
|
21
|
+
* ordinary players and is not an error.
|
|
22
|
+
*/
|
|
23
|
+
import { messageOf, type NetworkManager } from '../network/NetworkManager';
|
|
24
|
+
import { readScoped, writeScoped } from '../envScope';
|
|
25
|
+
import { chunkInput, chunkKey, type ChunkCoord } from '../realtime/space';
|
|
26
|
+
import {
|
|
27
|
+
NO_STUDIO_PERMISSIONS,
|
|
28
|
+
gridBoundsContain,
|
|
29
|
+
gridBoundsFrom,
|
|
30
|
+
singleChunkBounds,
|
|
31
|
+
studioPermissions,
|
|
32
|
+
type GridBounds,
|
|
33
|
+
type PlayerCodePermissionKey,
|
|
34
|
+
type StudioPermissions,
|
|
35
|
+
} from './permissions';
|
|
36
|
+
|
|
37
|
+
export interface GridSnapshot {
|
|
38
|
+
gridId: string;
|
|
39
|
+
bounds: GridBounds;
|
|
40
|
+
permissions: StudioPermissions;
|
|
41
|
+
/** Claimed by this player (from the registry or this browser). */
|
|
42
|
+
owned: boolean;
|
|
43
|
+
ownerName?: string;
|
|
44
|
+
policy?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OWNED_KEY = 'construct:owned-grids';
|
|
48
|
+
const REGISTRY_TTL_MS = 10_000;
|
|
49
|
+
|
|
50
|
+
interface OwnedRecord {
|
|
51
|
+
gridId: string;
|
|
52
|
+
bounds: GridBounds;
|
|
53
|
+
effectiveKeys: PlayerCodePermissionKey[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ClaimRecord {
|
|
57
|
+
gridId: string;
|
|
58
|
+
chunk: ChunkCoord;
|
|
59
|
+
ownerUserId: string;
|
|
60
|
+
ownerName: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A claim as the world hub lists it. */
|
|
64
|
+
interface HubClaim {
|
|
65
|
+
gridId: string;
|
|
66
|
+
chunk: ChunkCoord;
|
|
67
|
+
ownerUserId: string;
|
|
68
|
+
ownerName: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class GridService {
|
|
72
|
+
private owned = new Map<string, OwnedRecord>();
|
|
73
|
+
/** The registry's answer per chunk, cached briefly (visitors poll it as they walk). */
|
|
74
|
+
private registry = new Map<string, { at: number; claim: ClaimRecord | null }>();
|
|
75
|
+
private registryInFlight = new Map<string, Promise<ClaimRecord | null>>();
|
|
76
|
+
/** Grids this session already tried to put in the registry from `lookup`. */
|
|
77
|
+
private backfilled = new Set<string>();
|
|
78
|
+
|
|
79
|
+
constructor(private readonly network: NetworkManager) {
|
|
80
|
+
this.loadOwned();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private get hub() {
|
|
84
|
+
return this.network.worldHub;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Claim the chunk under `position` as a one-chunk grid owned by the player. */
|
|
88
|
+
async claimHere(chunk: ChunkCoord, ownerName: string): Promise<GridSnapshot> {
|
|
89
|
+
const appId = this.requireAppId();
|
|
90
|
+
const claimed = await this.network.game.marketplace.claimGridChunk({
|
|
91
|
+
appId,
|
|
92
|
+
chunk: chunkInput(chunk),
|
|
93
|
+
});
|
|
94
|
+
const bounds = gridBoundsFrom(claimed.lowChunk, claimed.highChunk) ?? singleChunkBounds(chunk);
|
|
95
|
+
const permissions = studioPermissions(claimed.effectivePermissionKeys);
|
|
96
|
+
const gridId = String(claimed.gridId);
|
|
97
|
+
this.owned.set(chunkKey(chunk), { gridId, bounds, effectiveKeys: permissions.effectiveKeys });
|
|
98
|
+
this.saveOwned();
|
|
99
|
+
await this.recordClaim(gridId, chunk, ownerName);
|
|
100
|
+
return {
|
|
101
|
+
gridId,
|
|
102
|
+
bounds,
|
|
103
|
+
permissions,
|
|
104
|
+
owned: true,
|
|
105
|
+
ownerName,
|
|
106
|
+
policy: claimed.policy ?? undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async release(gridId: string): Promise<void> {
|
|
111
|
+
const appId = this.requireAppId();
|
|
112
|
+
await this.network.game.marketplace.releaseClaimedGrid({ appId, gridId });
|
|
113
|
+
for (const [key, record] of this.owned) if (record.gridId === gridId) this.owned.delete(key);
|
|
114
|
+
this.saveOwned();
|
|
115
|
+
try {
|
|
116
|
+
await this.hub.call('release_claim', { gridId });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
this.network.log(`claim registry release failed: ${messageOf(error)}`);
|
|
119
|
+
}
|
|
120
|
+
this.registry.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The grid at `chunk` as this player may know it, or null for open world. */
|
|
124
|
+
async lookup(chunk: ChunkCoord): Promise<GridSnapshot | null> {
|
|
125
|
+
const mine = this.ownedAt(chunk);
|
|
126
|
+
if (mine) {
|
|
127
|
+
const refreshed = await this.refreshOwnedKeys(chunk, mine);
|
|
128
|
+
// A grid claimed before the registry existed (or from a browser whose registry write
|
|
129
|
+
// failed) gets its row on the next visit.
|
|
130
|
+
const registered = await this.claimAt(chunk);
|
|
131
|
+
if (registered?.gridId !== mine.gridId && !this.backfilled.has(mine.gridId)) {
|
|
132
|
+
this.backfilled.add(mine.gridId);
|
|
133
|
+
void this.recordClaim(mine.gridId, chunk, this.ownerNameHint());
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
gridId: mine.gridId,
|
|
137
|
+
bounds: mine.bounds,
|
|
138
|
+
permissions: studioPermissions(refreshed ?? mine.effectiveKeys),
|
|
139
|
+
owned: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const claim = await this.claimAt(chunk);
|
|
143
|
+
if (!claim) return null;
|
|
144
|
+
const owned = claim.ownerUserId === this.network.user?.userId;
|
|
145
|
+
if (owned) {
|
|
146
|
+
const record: OwnedRecord = {
|
|
147
|
+
gridId: claim.gridId,
|
|
148
|
+
bounds: singleChunkBounds(chunk),
|
|
149
|
+
effectiveKeys: [],
|
|
150
|
+
};
|
|
151
|
+
const refreshed = await this.refreshOwnedKeys(chunk, record);
|
|
152
|
+
if (refreshed && refreshed.length > 0) {
|
|
153
|
+
this.owned.set(chunkKey(chunk), { ...record, effectiveKeys: refreshed });
|
|
154
|
+
this.saveOwned();
|
|
155
|
+
return {
|
|
156
|
+
gridId: claim.gridId,
|
|
157
|
+
bounds: record.bounds,
|
|
158
|
+
permissions: studioPermissions(refreshed),
|
|
159
|
+
owned: true,
|
|
160
|
+
ownerName: claim.ownerName,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
gridId: claim.gridId,
|
|
166
|
+
bounds: singleChunkBounds(chunk),
|
|
167
|
+
permissions: NO_STUDIO_PERMISSIONS,
|
|
168
|
+
owned,
|
|
169
|
+
ownerName: claim.ownerName,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ownedGrids(): OwnedRecord[] {
|
|
174
|
+
return [...this.owned.values()];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
private ownedAt(chunk: ChunkCoord): OwnedRecord | null {
|
|
180
|
+
const direct = this.owned.get(chunkKey(chunk));
|
|
181
|
+
if (direct) return direct;
|
|
182
|
+
for (const record of this.owned.values())
|
|
183
|
+
if (gridBoundsContain(record.bounds, chunk)) return record;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Admin-only read; returns the keys when it works, null when it does not. */
|
|
188
|
+
private async refreshOwnedKeys(
|
|
189
|
+
chunk: ChunkCoord,
|
|
190
|
+
record: OwnedRecord,
|
|
191
|
+
): Promise<PlayerCodePermissionKey[] | null> {
|
|
192
|
+
const userId = this.network.user?.userId;
|
|
193
|
+
if (!userId) return null;
|
|
194
|
+
try {
|
|
195
|
+
const rows = (await this.network.game.gameApps.nearbyPermissions({
|
|
196
|
+
appId: this.requireAppId(),
|
|
197
|
+
userId,
|
|
198
|
+
lowChunk: chunkInput(chunk),
|
|
199
|
+
highChunk: chunkInput(chunk),
|
|
200
|
+
})) as Array<{ gridId?: unknown; permissionKeys?: unknown }>;
|
|
201
|
+
const row = rows.find((r) => String(r.gridId) === record.gridId);
|
|
202
|
+
if (!row || !Array.isArray(row.permissionKeys)) return null;
|
|
203
|
+
const keys = studioPermissions(row.permissionKeys).effectiveKeys;
|
|
204
|
+
if (keys.join() !== record.effectiveKeys.join()) {
|
|
205
|
+
record.effectiveKeys = keys;
|
|
206
|
+
this.saveOwned();
|
|
207
|
+
}
|
|
208
|
+
return keys;
|
|
209
|
+
} catch {
|
|
210
|
+
return null; // not an admin: the claim-time keys stand
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Records the claim for the signed-in player; the hub takes the owner from the caller. */
|
|
215
|
+
private async recordClaim(gridId: string, chunk: ChunkCoord, ownerName: string): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
await this.hub.call('record_claim', {
|
|
218
|
+
gridId,
|
|
219
|
+
chunk: { x: chunk.x, y: chunk.y, z: chunk.z },
|
|
220
|
+
ownerName: ownerName.slice(0, 32),
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// The claim itself succeeded; only the registry row failed (the world hub is not
|
|
224
|
+
// deployed, or another player's claim covers the chunk). Say so rather than hide it.
|
|
225
|
+
this.network.log(`claim registry write failed: ${messageOf(error)}`);
|
|
226
|
+
}
|
|
227
|
+
this.registry.delete(chunkKey(chunk));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The registry's claim at `chunk`, or null; a failed read counts as none until it expires. */
|
|
231
|
+
private claimAt(chunk: ChunkCoord): Promise<ClaimRecord | null> {
|
|
232
|
+
const key = chunkKey(chunk);
|
|
233
|
+
const cached = this.registry.get(key);
|
|
234
|
+
if (cached && Date.now() - cached.at < REGISTRY_TTL_MS) return Promise.resolve(cached.claim);
|
|
235
|
+
const inFlight = this.registryInFlight.get(key);
|
|
236
|
+
if (inFlight) return inFlight;
|
|
237
|
+
const read = (async () => {
|
|
238
|
+
let claim: ClaimRecord | null = null;
|
|
239
|
+
try {
|
|
240
|
+
const { claims } = await this.hub.call<{ claims: HubClaim[] }>('claims', {
|
|
241
|
+
chunk: { x: chunk.x, y: chunk.y, z: chunk.z },
|
|
242
|
+
});
|
|
243
|
+
const row = claims[0];
|
|
244
|
+
if (row) {
|
|
245
|
+
claim = {
|
|
246
|
+
gridId: String(row.gridId),
|
|
247
|
+
chunk: { x: Number(row.chunk.x), y: Number(row.chunk.y), z: Number(row.chunk.z) },
|
|
248
|
+
ownerUserId: String(row.ownerUserId),
|
|
249
|
+
ownerName: String(row.ownerName ?? ''),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.network.log(`claim registry unavailable: ${messageOf(error)}`);
|
|
254
|
+
}
|
|
255
|
+
this.registry.set(key, { at: Date.now(), claim });
|
|
256
|
+
this.registryInFlight.delete(key);
|
|
257
|
+
return claim;
|
|
258
|
+
})();
|
|
259
|
+
this.registryInFlight.set(key, read);
|
|
260
|
+
return read;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private ownerNameHint(): string {
|
|
264
|
+
const user = this.network.user;
|
|
265
|
+
return (user?.gamertag || user?.email?.split('@')[0] || 'player').slice(0, 32);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private requireAppId(): string {
|
|
269
|
+
const appId = this.network.appId;
|
|
270
|
+
if (!appId) throw new Error('Enter an app before using grids');
|
|
271
|
+
return appId;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private loadOwned(): void {
|
|
275
|
+
const raw = readScoped(OWNED_KEY);
|
|
276
|
+
if (!raw) return;
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(raw) as Record<string, Partial<OwnedRecord>>;
|
|
279
|
+
this.owned = new Map(
|
|
280
|
+
Object.entries(parsed)
|
|
281
|
+
.filter(([, v]) => v.gridId && v.bounds)
|
|
282
|
+
.map(([k, v]) => [
|
|
283
|
+
k,
|
|
284
|
+
{ gridId: v.gridId!, bounds: v.bounds!, effectiveKeys: v.effectiveKeys ?? [] },
|
|
285
|
+
]),
|
|
286
|
+
);
|
|
287
|
+
} catch {
|
|
288
|
+
this.owned = new Map();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private saveOwned(): void {
|
|
293
|
+
writeScoped(OWNED_KEY, JSON.stringify(Object.fromEntries(this.owned)));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ACTIVE_WINDOW_MS, HumanInputMonitor } from './HumanInputMonitor';
|
|
4
|
+
|
|
5
|
+
function fakeWindow() {
|
|
6
|
+
const listeners = new Map<string, Set<EventListener>>();
|
|
7
|
+
return {
|
|
8
|
+
listeners,
|
|
9
|
+
addEventListener(type: string, listener: EventListener) {
|
|
10
|
+
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
11
|
+
listeners.get(type)!.add(listener);
|
|
12
|
+
},
|
|
13
|
+
removeEventListener(type: string, listener: EventListener) {
|
|
14
|
+
listeners.get(type)?.delete(listener);
|
|
15
|
+
},
|
|
16
|
+
fire(type: string) {
|
|
17
|
+
for (const listener of listeners.get(type) ?? []) listener(new Event(type));
|
|
18
|
+
},
|
|
19
|
+
} as unknown as Window & { fire(type: string): void; listeners: Map<string, Set<EventListener>> };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('HumanInputMonitor', () => {
|
|
23
|
+
it('reports no human input until the player touches the controls', () => {
|
|
24
|
+
let now = 10_000;
|
|
25
|
+
const win = fakeWindow();
|
|
26
|
+
const monitor = new HumanInputMonitor(win, () => now);
|
|
27
|
+
expect(monitor.active()).toBe(false);
|
|
28
|
+
|
|
29
|
+
win.fire('keydown');
|
|
30
|
+
expect(monitor.active()).toBe(true);
|
|
31
|
+
|
|
32
|
+
now += ACTIVE_WINDOW_MS - 1;
|
|
33
|
+
expect(monitor.active()).toBe(true);
|
|
34
|
+
now += 2;
|
|
35
|
+
expect(monitor.active()).toBe(false);
|
|
36
|
+
|
|
37
|
+
win.fire('pointermove');
|
|
38
|
+
expect(monitor.active()).toBe(true);
|
|
39
|
+
monitor.dispose();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('detaches every listener on dispose', () => {
|
|
43
|
+
const win = fakeWindow();
|
|
44
|
+
const monitor = new HumanInputMonitor(win, () => 0);
|
|
45
|
+
const attached = [...win.listeners.values()].reduce((n, set) => n + set.size, 0);
|
|
46
|
+
expect(attached).toBeGreaterThan(0);
|
|
47
|
+
monitor.dispose();
|
|
48
|
+
const remaining = [...win.listeners.values()].reduce((n, set) => n + set.size, 0);
|
|
49
|
+
expect(remaining).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers one question for the player-host observation frame: is a human at
|
|
3
|
+
* the controls right now?
|
|
4
|
+
*
|
|
5
|
+
* The frame's `humanInputActive` used to come from the Crowdy Agent control
|
|
6
|
+
* gate, which the in-browser harness replaced. The harness only observes (it
|
|
7
|
+
* has no dispatch path), but the observation is still what the model reads,
|
|
8
|
+
* so it must be true rather than a hard-coded `false`. "Active" means a key,
|
|
9
|
+
* pointer or wheel event on the page within the last {@link ACTIVE_WINDOW_MS}.
|
|
10
|
+
*/
|
|
11
|
+
export const ACTIVE_WINDOW_MS = 1_500;
|
|
12
|
+
|
|
13
|
+
const EVENTS: ReadonlyArray<keyof WindowEventMap> = [
|
|
14
|
+
'keydown',
|
|
15
|
+
'pointerdown',
|
|
16
|
+
'pointermove',
|
|
17
|
+
'wheel',
|
|
18
|
+
'touchstart',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export class HumanInputMonitor {
|
|
22
|
+
private lastInputAt = 0;
|
|
23
|
+
private readonly target: Window | null;
|
|
24
|
+
private readonly onInput = (): void => {
|
|
25
|
+
this.lastInputAt = this.now();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
target: Window | null = typeof window === 'undefined' ? null : window,
|
|
30
|
+
private readonly now: () => number = () => Date.now(),
|
|
31
|
+
) {
|
|
32
|
+
this.target = target;
|
|
33
|
+
for (const type of EVENTS) {
|
|
34
|
+
this.target?.addEventListener(type, this.onInput, { passive: true, capture: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when the player touched the controls within `withinMs`. */
|
|
39
|
+
active(withinMs = ACTIVE_WINDOW_MS): boolean {
|
|
40
|
+
return this.lastInputAt > 0 && this.now() - this.lastInputAt < withinMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
dispose(): void {
|
|
44
|
+
for (const type of EVENTS) {
|
|
45
|
+
this.target?.removeEventListener(type, this.onInput, { capture: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|