meshpad 0.0.0-stage → 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.
- package/README.md +221 -2
- package/addons.d.ts +1 -0
- package/bin/meshpad.js +107 -0
- package/index.d.ts +133 -0
- package/index.js +3 -0
- package/package.json +72 -4
- package/src/addons.js +1 -0
- package/src/capture.js +143 -0
- package/src/dispose.js +26 -0
- package/src/failure.js +28 -0
- package/src/game.js +311 -0
- package/src/hud.js +198 -0
- package/src/styles.css +208 -0
- package/src/three.js +1 -0
- package/src/tooling.js +128 -0
- package/src/typecheck.js +88 -0
- package/templates/game.ts +67 -0
- package/three.d.ts +1 -0
- package/tsconfig.json +17 -0
package/src/capture.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, extname, resolve } from 'node:path';
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
import { startDev } from './tooling.js';
|
|
5
|
+
|
|
6
|
+
const pngPath = (path) => {
|
|
7
|
+
if (typeof path !== 'string' || extname(path).toLowerCase() !== '.png') throw new Error('Screenshot output must end in .png.');
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Validate the whole script before launching a game or writing screenshots. */
|
|
12
|
+
export function validateSteps(steps) {
|
|
13
|
+
if (!Array.isArray(steps)) throw new TypeError('Script must be a JSON array of steps.');
|
|
14
|
+
for (const [index, step] of steps.entries()) {
|
|
15
|
+
try {
|
|
16
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) throw new Error('Expected a step object.');
|
|
17
|
+
switch (step.type) {
|
|
18
|
+
case 'pointerdown': case 'pointermove': case 'pointerup':
|
|
19
|
+
if (step.type === 'pointerup' && step.world === undefined) break;
|
|
20
|
+
if (!Array.isArray(step.world) || step.world.length !== 3 || !step.world.every(Number.isFinite)) {
|
|
21
|
+
throw new Error('world must be [x, y, z] with finite coordinates.');
|
|
22
|
+
}
|
|
23
|
+
break;
|
|
24
|
+
case 'advance':
|
|
25
|
+
if (!Number.isFinite(step.seconds) || step.seconds < 0 || step.seconds > 60) throw new Error('seconds must be between 0 and 60.');
|
|
26
|
+
break;
|
|
27
|
+
case 'screenshot': pngPath(step.output); break;
|
|
28
|
+
case 'state':
|
|
29
|
+
if (step.label !== undefined && typeof step.label !== 'string') throw new Error('label must be a string.');
|
|
30
|
+
break;
|
|
31
|
+
default: throw new Error(`Unknown step type: ${step.type}.`);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) { throw new Error(`Script step ${index + 1}: ${error.message}`, { cause: error }); }
|
|
34
|
+
}
|
|
35
|
+
return steps;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render and scripted play share the same browser, clock, input, and capture path. */
|
|
39
|
+
export async function captureGame(entry, options = {}) {
|
|
40
|
+
const width = options.width ?? 390;
|
|
41
|
+
const height = options.height ?? 844;
|
|
42
|
+
const dpr = options.dpr ?? 1;
|
|
43
|
+
const time = options.time ?? 0;
|
|
44
|
+
const level = options.level ?? 1;
|
|
45
|
+
const timeout = options.timeout ?? 30000;
|
|
46
|
+
for (const [name, size] of Object.entries({ width, height })) {
|
|
47
|
+
if (!Number.isInteger(size) || size < 1 || size > 4096) throw new RangeError(`${name} must be an integer from 1 to 4096.`);
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isFinite(dpr) || dpr < 0.25 || dpr > 4) throw new RangeError('dpr must be between 0.25 and 4.');
|
|
50
|
+
if (!Number.isFinite(time) || time < 0 || time > 60) throw new RangeError('time must be between 0 and 60 seconds.');
|
|
51
|
+
if (!Number.isInteger(level) || level < 1) throw new RangeError('level must be a positive integer (starting at 1).');
|
|
52
|
+
if (!Number.isInteger(timeout) || timeout < 1) throw new RangeError('timeout must be a positive integer in milliseconds.');
|
|
53
|
+
const output = resolve(pngPath(options.output ?? 'renders/game.png'));
|
|
54
|
+
const scriptPath = options.script ? resolve(options.script) : null;
|
|
55
|
+
const steps = scriptPath ? validateSteps(JSON.parse(await readFile(scriptPath, 'utf8'))) : [];
|
|
56
|
+
const server = await startDev(entry, { root: options.root, port: 0, logLevel: 'error' });
|
|
57
|
+
let browser;
|
|
58
|
+
try {
|
|
59
|
+
try {
|
|
60
|
+
browser = await chromium.launch({ headless: true, args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error('Could not launch Chromium. Run "meshpad browsers" once (Linux CI: "meshpad browsers --with-deps").', { cause: error });
|
|
63
|
+
}
|
|
64
|
+
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: dpr });
|
|
65
|
+
page.setDefaultTimeout(timeout);
|
|
66
|
+
const errors = [];
|
|
67
|
+
page.on('pageerror', (error) => errors.push(error.message));
|
|
68
|
+
const address = server.httpServer.address();
|
|
69
|
+
await page.goto(`http://127.0.0.1:${address.port}/?__meshpad_capture&__meshpad_dpr=${dpr}`, { waitUntil: 'load', timeout });
|
|
70
|
+
await page.waitForFunction(() => Boolean(window.__MESHPAD__), null, { timeout });
|
|
71
|
+
// Bound asynchronous user setup as well as browser navigation.
|
|
72
|
+
await page.evaluate(async ({ level, time, timeout }) => {
|
|
73
|
+
const run = async () => {
|
|
74
|
+
const game = window.__MESHPAD__;
|
|
75
|
+
await game.ready;
|
|
76
|
+
if (game.state.index !== level - 1) await game.loadLevel(level - 1);
|
|
77
|
+
await document.fonts.ready;
|
|
78
|
+
if (time > 0) game.step(time);
|
|
79
|
+
};
|
|
80
|
+
let timer;
|
|
81
|
+
try {
|
|
82
|
+
await Promise.race([run(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Game setup timed out.')), timeout); })]);
|
|
83
|
+
} finally { clearTimeout(timer); }
|
|
84
|
+
}, { level, time, timeout });
|
|
85
|
+
|
|
86
|
+
const inspect = async () => {
|
|
87
|
+
if (errors.length) throw new Error(`Game failed: ${errors.join('\n')}`);
|
|
88
|
+
return page.evaluate(() => {
|
|
89
|
+
const game = window.__MESHPAD__;
|
|
90
|
+
if (game.state.status === 'error') throw game.state.error;
|
|
91
|
+
let debug;
|
|
92
|
+
try { debug = JSON.parse(JSON.stringify(game.debug())); }
|
|
93
|
+
catch (error) { throw new Error(`debug() must return JSON-serializable state: ${error.message}`); }
|
|
94
|
+
return {
|
|
95
|
+
state: { ...game.state, error: null }, debug,
|
|
96
|
+
three: { calls: game.renderer.info.render.calls, triangles: game.renderer.info.render.triangles },
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const metadata = { width, height, dpr, level, time };
|
|
101
|
+
if (options.sceneOnly) await page.locator('.meshpad-hud').evaluate((element) => { element.style.display = 'none'; });
|
|
102
|
+
const save = async (path, extra = {}) => {
|
|
103
|
+
await page.evaluate(() => window.__MESHPAD__.step(0));
|
|
104
|
+
const result = { ...metadata, ...await inspect(), ...extra };
|
|
105
|
+
await mkdir(dirname(path), { recursive: true });
|
|
106
|
+
await page.locator(options.sceneOnly ? '.meshpad-canvas' : '.meshpad-stage').screenshot({ path, timeout, animations: 'disabled' });
|
|
107
|
+
await writeFile(path.slice(0, -4) + '.json', JSON.stringify(result, null, 2) + '\n');
|
|
108
|
+
return { output: path, ...result };
|
|
109
|
+
};
|
|
110
|
+
await inspect();
|
|
111
|
+
const snapshots = [];
|
|
112
|
+
for (const [index, step] of steps.entries()) {
|
|
113
|
+
try {
|
|
114
|
+
if (step.type.startsWith('pointer')) {
|
|
115
|
+
if (step.world) {
|
|
116
|
+
const point = await page.evaluate((world) => {
|
|
117
|
+
const game = window.__MESHPAD__;
|
|
118
|
+
game.camera.updateWorldMatrix(true, false);
|
|
119
|
+
const point = game.camera.position.clone().set(...world).project(game.camera);
|
|
120
|
+
if (![point.x, point.y, point.z].every(Number.isFinite) || Math.abs(point.z) > 1) throw new Error('World position is outside the camera depth range.');
|
|
121
|
+
const rect = game.renderer.domElement.getBoundingClientRect();
|
|
122
|
+
return { x: rect.left + (point.x + 1) * rect.width / 2, y: rect.top + (1 - point.y) * rect.height / 2 };
|
|
123
|
+
}, step.world);
|
|
124
|
+
await page.mouse.move(point.x, point.y);
|
|
125
|
+
}
|
|
126
|
+
if (step.type === 'pointerdown') await page.mouse.down();
|
|
127
|
+
if (step.type === 'pointerup') await page.mouse.up();
|
|
128
|
+
} else if (step.type === 'advance') {
|
|
129
|
+
await page.evaluate((seconds) => window.__MESHPAD__.step(seconds), step.seconds);
|
|
130
|
+
} else if (step.type === 'screenshot') {
|
|
131
|
+
const capture = await save(resolve(dirname(scriptPath), step.output));
|
|
132
|
+
snapshots.push({ step: index + 1, type: step.type, ...capture });
|
|
133
|
+
} else if (step.type === 'state') {
|
|
134
|
+
snapshots.push({ step: index + 1, type: step.type, ...(step.label === undefined ? {} : { label: step.label }), ...await inspect() });
|
|
135
|
+
}
|
|
136
|
+
await inspect();
|
|
137
|
+
} catch (error) { throw new Error(`Script step ${index + 1} (${step.type}): ${error.message}`, { cause: error }); }
|
|
138
|
+
}
|
|
139
|
+
return await save(output, scriptPath ? { snapshots } : {});
|
|
140
|
+
} finally {
|
|
141
|
+
try { await browser?.close(); } finally { await server.close(); }
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/dispose.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// A level owns its scene resources. Deduplicate shared resources within a scene.
|
|
2
|
+
export function disposeScene(scene) {
|
|
3
|
+
const resources = new Set();
|
|
4
|
+
const textures = new Set();
|
|
5
|
+
const collect = (value) => {
|
|
6
|
+
if (value?.isTexture) textures.add(value);
|
|
7
|
+
else if (Array.isArray(value)) value.forEach(collect);
|
|
8
|
+
};
|
|
9
|
+
scene.traverse((object) => {
|
|
10
|
+
if (object.geometry) resources.add(object.geometry);
|
|
11
|
+
if (object.skeleton) resources.add(object.skeleton);
|
|
12
|
+
if (object.shadow) resources.add(object.shadow);
|
|
13
|
+
for (const material of [object.material].flat().filter(Boolean)) {
|
|
14
|
+
resources.add(material);
|
|
15
|
+
Object.values(material).forEach(collect);
|
|
16
|
+
Object.values(material.uniforms ?? {}).forEach((uniform) => collect(uniform.value));
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
collect(scene.background);
|
|
20
|
+
collect(scene.environment);
|
|
21
|
+
for (const texture of textures) texture.dispose();
|
|
22
|
+
for (const resource of resources) resource.dispose();
|
|
23
|
+
scene.clear();
|
|
24
|
+
scene.background = null;
|
|
25
|
+
scene.environment = null;
|
|
26
|
+
}
|
package/src/failure.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Validate and snapshot a failure mode before allocating a renderer. */
|
|
2
|
+
export function createFailure(config = { type: 'default' }) {
|
|
3
|
+
if (!config || typeof config !== 'object') throw new TypeError('failure must be a failure mode object.');
|
|
4
|
+
const { type } = config;
|
|
5
|
+
if (type === 'default') return { type, initial: null, remaining: null, playOnAmount: null };
|
|
6
|
+
let initial;
|
|
7
|
+
let playOnAmount;
|
|
8
|
+
if (type === 'hearts') {
|
|
9
|
+
initial = config.hearts ?? 3;
|
|
10
|
+
if (!Number.isInteger(initial) || initial < 3 || initial > 5) throw new RangeError('failure.hearts must be 3, 4, or 5.');
|
|
11
|
+
playOnAmount = initial;
|
|
12
|
+
} else if (type === 'time') {
|
|
13
|
+
initial = config.seconds;
|
|
14
|
+
playOnAmount = config.playOnSeconds ?? 20;
|
|
15
|
+
if (![initial, playOnAmount].every((n) => Number.isFinite(n) && n > 0)) {
|
|
16
|
+
throw new RangeError('failure.seconds and failure.playOnSeconds must be positive finite numbers.');
|
|
17
|
+
}
|
|
18
|
+
} else if (type === 'moves') {
|
|
19
|
+
initial = config.moves;
|
|
20
|
+
playOnAmount = config.playOnMoves ?? 10;
|
|
21
|
+
if (![initial, playOnAmount].every((n) => Number.isSafeInteger(n) && n > 0)) {
|
|
22
|
+
throw new RangeError('failure.moves and failure.playOnMoves must be positive safe integers.');
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
throw new TypeError('failure.type must be default, hearts, time, or moves.');
|
|
26
|
+
}
|
|
27
|
+
return { type, initial, remaining: initial, playOnAmount };
|
|
28
|
+
}
|
package/src/game.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { createHUD } from './hud.js';
|
|
3
|
+
import { disposeScene } from './dispose.js';
|
|
4
|
+
import { createFailure } from './failure.js';
|
|
5
|
+
|
|
6
|
+
/** Create a mobile-first game. Every level gets its own scene and lifetime. */
|
|
7
|
+
export function createGame(options = {}) {
|
|
8
|
+
const levels = options.levels;
|
|
9
|
+
if (!Array.isArray(levels) || levels.length === 0 || levels.some((level) => typeof level?.setup !== 'function')) {
|
|
10
|
+
throw new TypeError('Provide at least one level with a setup(context) function.');
|
|
11
|
+
}
|
|
12
|
+
if (levels.some((level) => level.difficulty !== undefined && !['normal', 'hard', 'very-hard', 'ultra-hard'].includes(level.difficulty))) {
|
|
13
|
+
throw new TypeError('Level difficulty must be normal, hard, very-hard, or ultra-hard.');
|
|
14
|
+
}
|
|
15
|
+
const initial = options.initialLevel ?? 0;
|
|
16
|
+
if (!Number.isInteger(initial) || initial < 0 || initial >= levels.length) throw new RangeError('initialLevel is out of range.');
|
|
17
|
+
const defaultFailure = createFailure(options.failure);
|
|
18
|
+
const failures = levels.map((level) => level.failure === undefined ? { ...defaultFailure } : createFailure(level.failure));
|
|
19
|
+
const maxPixelRatio = options.maxPixelRatio ?? 2;
|
|
20
|
+
const maxWidth = options.maxWidth ?? 540;
|
|
21
|
+
if (!(Number.isFinite(maxPixelRatio) && maxPixelRatio > 0 && Number.isFinite(maxWidth) && maxWidth > 0)) {
|
|
22
|
+
throw new RangeError('maxPixelRatio and maxWidth must be positive finite numbers.');
|
|
23
|
+
}
|
|
24
|
+
const root = typeof options.root === 'string' ? document.querySelector(options.root) : options.root ?? document.body;
|
|
25
|
+
if (!(root instanceof HTMLElement)) throw new Error('Game root was not found.');
|
|
26
|
+
if (root.querySelector('.meshpad-stage')) throw new Error('This root already contains a Meshpad game.');
|
|
27
|
+
const stage = document.createElement('main');
|
|
28
|
+
stage.className = 'meshpad-stage';
|
|
29
|
+
root.classList.add('meshpad-root');
|
|
30
|
+
root.append(stage);
|
|
31
|
+
let renderer;
|
|
32
|
+
try {
|
|
33
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance', ...options.renderer });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
stage.remove(); root.classList.remove('meshpad-root');
|
|
36
|
+
throw new Error('Meshpad needs a browser with WebGL 2 enabled.', { cause: error });
|
|
37
|
+
}
|
|
38
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
39
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
40
|
+
renderer.domElement.className = 'meshpad-canvas';
|
|
41
|
+
renderer.domElement.setAttribute('aria-label', options.title ?? '3D game');
|
|
42
|
+
stage.append(renderer.domElement);
|
|
43
|
+
if (options.title) document.title = options.title;
|
|
44
|
+
|
|
45
|
+
const params = new URLSearchParams(location.search);
|
|
46
|
+
const capture = params.has('__meshpad_capture');
|
|
47
|
+
const captureDpr = capture ? Number(params.get('__meshpad_dpr')) : 0;
|
|
48
|
+
const lifetime = new AbortController();
|
|
49
|
+
const listen = (target, type, handler, extra = {}) => target.addEventListener(type, handler, { ...extra, signal: lifetime.signal });
|
|
50
|
+
let current;
|
|
51
|
+
let status = 'loading';
|
|
52
|
+
let disposed = false;
|
|
53
|
+
let contextLost = false;
|
|
54
|
+
let elapsed = 0;
|
|
55
|
+
let previousTime;
|
|
56
|
+
let frame = 0;
|
|
57
|
+
let resizeFrame = 0;
|
|
58
|
+
let generation = 0;
|
|
59
|
+
let width = 1;
|
|
60
|
+
let height = 1;
|
|
61
|
+
let manuallyPaused = false;
|
|
62
|
+
let levelMenuOpen = false;
|
|
63
|
+
let lastError = null;
|
|
64
|
+
const invoke = (callback) => { Promise.resolve().then(callback).catch(() => {}); };
|
|
65
|
+
const hud = createHUD(stage, levels, {
|
|
66
|
+
next: () => invoke(() => game.next()),
|
|
67
|
+
restart: () => invoke(() => game.restart()),
|
|
68
|
+
playOn: () => game.playOn(),
|
|
69
|
+
loadLevel: (index) => invoke(() => game.loadLevel(index)),
|
|
70
|
+
openLevels() {
|
|
71
|
+
levelMenuOpen = true;
|
|
72
|
+
if (status === 'playing') status = 'paused';
|
|
73
|
+
stopLoop();
|
|
74
|
+
},
|
|
75
|
+
closeLevels() {
|
|
76
|
+
levelMenuOpen = false;
|
|
77
|
+
if (status === 'paused' && !manuallyPaused) status = 'playing';
|
|
78
|
+
startLoop();
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const isCurrent = (session) => Boolean(session) && !disposed && current === session && !session.controller.signal.aborted;
|
|
82
|
+
const report = (error, session = current) => {
|
|
83
|
+
if (!isCurrent(session)) return;
|
|
84
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
85
|
+
status = 'error';
|
|
86
|
+
levelMenuOpen = false;
|
|
87
|
+
stopLoop();
|
|
88
|
+
hud.result('error');
|
|
89
|
+
options.onError?.(lastError);
|
|
90
|
+
};
|
|
91
|
+
const draw = () => {
|
|
92
|
+
if (current?.hooks && !contextLost && !disposed) {
|
|
93
|
+
try {
|
|
94
|
+
current.hooks.beforeRender?.();
|
|
95
|
+
renderer.render(current.scene, current.camera);
|
|
96
|
+
} catch (error) { report(error); }
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const advance = (dt) => {
|
|
100
|
+
if (status !== 'playing') return;
|
|
101
|
+
elapsed += dt;
|
|
102
|
+
try { current.hooks?.update?.(dt, elapsed); } catch (error) { report(error); }
|
|
103
|
+
if (status === 'playing' && current.failure.type === 'time') {
|
|
104
|
+
current.failure.remaining = Math.max(0, current.failure.remaining - dt);
|
|
105
|
+
// Absorb floating point residue from fixed simulation substeps.
|
|
106
|
+
if (current.failure.remaining < 1e-9) current.failure.remaining = 0;
|
|
107
|
+
hud.resource(current.failure);
|
|
108
|
+
if (current.failure.remaining === 0) finish(current, 'failed');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
function stopLoop() { cancelAnimationFrame(frame); frame = 0; previousTime = undefined; }
|
|
112
|
+
const shouldRun = () => !capture && !disposed && !contextLost && !document.hidden && !manuallyPaused && status === 'playing';
|
|
113
|
+
const tick = (time) => {
|
|
114
|
+
frame = 0;
|
|
115
|
+
if (!shouldRun()) return;
|
|
116
|
+
const dt = previousTime == null ? 0 : Math.min((time - previousTime) / 1000, 0.05);
|
|
117
|
+
previousTime = time;
|
|
118
|
+
advance(dt);
|
|
119
|
+
draw();
|
|
120
|
+
if (shouldRun()) frame = requestAnimationFrame(tick);
|
|
121
|
+
};
|
|
122
|
+
const startLoop = () => { if (!frame && shouldRun()) frame = requestAnimationFrame(tick); };
|
|
123
|
+
const finish = (session, outcome) => {
|
|
124
|
+
if (!isCurrent(session) || !['playing', 'paused'].includes(status)) return false;
|
|
125
|
+
status = outcome;
|
|
126
|
+
levelMenuOpen = false;
|
|
127
|
+
stopLoop();
|
|
128
|
+
hud.result(outcome, session.failure);
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
const consume = (session, type) => {
|
|
132
|
+
if (!isCurrent(session) || status !== 'playing' || session.failure.type !== type) return false;
|
|
133
|
+
session.failure.remaining = Math.max(0, session.failure.remaining - 1);
|
|
134
|
+
hud.resource(session.failure);
|
|
135
|
+
if (session.failure.remaining === 0) finish(session, 'failed');
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
const resize = () => {
|
|
139
|
+
if (disposed) return;
|
|
140
|
+
const viewport = window.visualViewport;
|
|
141
|
+
const fullPage = root === document.body;
|
|
142
|
+
const bounds = root.getBoundingClientRect();
|
|
143
|
+
const availableWidth = fullPage ? viewport?.width ?? innerWidth : bounds.width;
|
|
144
|
+
height = Math.max(1, Math.round(fullPage ? viewport?.height ?? innerHeight : bounds.height));
|
|
145
|
+
width = Math.max(1, Math.round(Math.min(availableWidth, maxWidth, height * 9 / 16)));
|
|
146
|
+
if (fullPage) {
|
|
147
|
+
root.style.height = `${height}px`;
|
|
148
|
+
root.style.width = `${availableWidth}px`;
|
|
149
|
+
root.style.top = `${viewport?.offsetTop ?? 0}px`;
|
|
150
|
+
root.style.left = `${viewport?.offsetLeft ?? 0}px`;
|
|
151
|
+
}
|
|
152
|
+
stage.style.width = `${width}px`;
|
|
153
|
+
renderer.setPixelRatio(captureDpr > 0 && captureDpr <= 4 ? captureDpr : Math.min(window.devicePixelRatio || 1, maxPixelRatio));
|
|
154
|
+
renderer.setSize(width, height, false);
|
|
155
|
+
if (current) {
|
|
156
|
+
if (current.autoResizeCamera && current.camera.isPerspectiveCamera) {
|
|
157
|
+
current.camera.aspect = width / height;
|
|
158
|
+
current.camera.updateProjectionMatrix();
|
|
159
|
+
}
|
|
160
|
+
try { current.hooks?.resize?.(width, height); draw(); } catch (error) { report(error); }
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const scheduleResize = () => {
|
|
164
|
+
cancelAnimationFrame(resizeFrame);
|
|
165
|
+
resizeFrame = requestAnimationFrame(resize);
|
|
166
|
+
};
|
|
167
|
+
const cleanup = (session) => {
|
|
168
|
+
if (!session) return;
|
|
169
|
+
session.controller.abort();
|
|
170
|
+
if (session.hooks && !session.hooksDisposed) {
|
|
171
|
+
session.hooksDisposed = true;
|
|
172
|
+
try { session.hooks.dispose?.(); } catch (error) { console.error('Level cleanup failed:', error); }
|
|
173
|
+
}
|
|
174
|
+
disposeScene(session.scene);
|
|
175
|
+
};
|
|
176
|
+
const loadLevel = async (index) => {
|
|
177
|
+
if (disposed) throw new Error('This game has been disposed.');
|
|
178
|
+
if (!Number.isInteger(index) || index < 0 || index >= levels.length) throw new RangeError('Level index is out of range.');
|
|
179
|
+
const version = ++generation;
|
|
180
|
+
stopLoop();
|
|
181
|
+
cleanup(current);
|
|
182
|
+
const scene = new THREE.Scene();
|
|
183
|
+
scene.background = new THREE.Color(options.background ?? '#221e4e');
|
|
184
|
+
scene.add(new THREE.HemisphereLight(0xefe3ff, 0x30245e, 2.5));
|
|
185
|
+
const light = new THREE.DirectionalLight(0xfff4d8, 3);
|
|
186
|
+
light.position.set(4, 8, 5);
|
|
187
|
+
scene.add(light);
|
|
188
|
+
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 200);
|
|
189
|
+
camera.position.set(5, 6, 9);
|
|
190
|
+
camera.lookAt(0, 0, 0);
|
|
191
|
+
const session = { index, scene, camera, failure: { ...failures[index] }, autoResizeCamera: true, controller: new AbortController(), hooks: null };
|
|
192
|
+
current = session;
|
|
193
|
+
status = 'loading'; elapsed = 0; lastError = null;
|
|
194
|
+
levelMenuOpen = false;
|
|
195
|
+
hud.reset(index, session.failure);
|
|
196
|
+
const context = {
|
|
197
|
+
scene, camera, renderer, level: levels[index], index,
|
|
198
|
+
signal: session.controller.signal,
|
|
199
|
+
get width() { return width; }, get height() { return height; },
|
|
200
|
+
get failure() { return Object.freeze({ ...session.failure }); },
|
|
201
|
+
win: () => finish(session, 'won'),
|
|
202
|
+
fail: () => finish(session, 'failed'),
|
|
203
|
+
mistake: () => consume(session, 'hearts'),
|
|
204
|
+
move: () => consume(session, 'moves'),
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const hooks = await levels[index].setup(context);
|
|
208
|
+
session.hooks = hooks ?? {};
|
|
209
|
+
if (disposed || version !== generation) {
|
|
210
|
+
cleanup(session);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
session.camera = session.hooks.camera ?? camera;
|
|
214
|
+
session.autoResizeCamera = session.hooks.autoResizeCamera ?? !session.hooks.camera;
|
|
215
|
+
status = manuallyPaused || levelMenuOpen ? 'paused' : 'playing';
|
|
216
|
+
resize();
|
|
217
|
+
startLoop();
|
|
218
|
+
return true;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isCurrent(session)) { cleanup(session); return false; }
|
|
221
|
+
report(error, session);
|
|
222
|
+
cleanup(session);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const game = {
|
|
227
|
+
renderer,
|
|
228
|
+
get scene() { return current?.scene; },
|
|
229
|
+
get camera() { return current?.camera; },
|
|
230
|
+
get state() { return Object.freeze({ status, index: current?.index ?? initial, elapsed, error: lastError,
|
|
231
|
+
failure: Object.freeze({ ...(current?.failure ?? failures[initial]) }),
|
|
232
|
+
}); },
|
|
233
|
+
debug: () => isCurrent(current) ? current.hooks?.debug?.() ?? null : null,
|
|
234
|
+
loadLevel,
|
|
235
|
+
restart: () => loadLevel(current?.index ?? initial),
|
|
236
|
+
next: () => loadLevel(Math.min((current?.index ?? initial) + 1, levels.length - 1)),
|
|
237
|
+
previous: () => loadLevel(Math.max((current?.index ?? initial) - 1, 0)),
|
|
238
|
+
win: () => finish(current, 'won'),
|
|
239
|
+
fail: () => finish(current, 'failed'),
|
|
240
|
+
mistake: () => consume(current, 'hearts'),
|
|
241
|
+
move: () => consume(current, 'moves'),
|
|
242
|
+
playOn() {
|
|
243
|
+
if (!isCurrent(current) || status !== 'failed' || current.failure.type === 'default') return false;
|
|
244
|
+
const failure = current.failure;
|
|
245
|
+
failure.remaining = failure.type === 'hearts' ? failure.initial : failure.remaining + failure.playOnAmount;
|
|
246
|
+
status = manuallyPaused ? 'paused' : 'playing';
|
|
247
|
+
hud.resource(failure);
|
|
248
|
+
hud.hideResult();
|
|
249
|
+
try { current.hooks?.onPlayOn?.(); } catch (error) { report(error); return false; }
|
|
250
|
+
draw();
|
|
251
|
+
startLoop();
|
|
252
|
+
return true;
|
|
253
|
+
},
|
|
254
|
+
pause() { manuallyPaused = true; if (status === 'playing') status = 'paused'; stopLoop(); },
|
|
255
|
+
resume() { manuallyPaused = false; if (status === 'paused' && !levelMenuOpen) status = 'playing'; startLoop(); },
|
|
256
|
+
step(seconds = 1 / 60) {
|
|
257
|
+
if (!Number.isFinite(seconds) || seconds < 0 || seconds > 60) throw new RangeError('step seconds must be between 0 and 60.');
|
|
258
|
+
if (disposed) throw new Error('This game has been disposed.');
|
|
259
|
+
const steps = Math.ceil(seconds * 60);
|
|
260
|
+
for (let i = 0; i < steps; i++) advance(seconds / steps);
|
|
261
|
+
draw();
|
|
262
|
+
},
|
|
263
|
+
dispose() {
|
|
264
|
+
if (disposed) return;
|
|
265
|
+
disposed = true; generation++; status = 'disposed';
|
|
266
|
+
stopLoop(); cancelAnimationFrame(resizeFrame);
|
|
267
|
+
lifetime.abort(); observer.disconnect(); cleanup(current); hud.dispose();
|
|
268
|
+
renderer.dispose(); renderer.forceContextLoss(); stage.remove();
|
|
269
|
+
root.classList.remove('meshpad-root');
|
|
270
|
+
for (const [property, value] of Object.entries(originalStyle)) root.style[property] = value;
|
|
271
|
+
if (window.__MESHPAD__ === game) delete window.__MESHPAD__;
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
const originalStyle = Object.fromEntries(['height', 'width', 'top', 'left'].map((key) => [key, root.style[key]]));
|
|
275
|
+
const observer = new ResizeObserver(scheduleResize);
|
|
276
|
+
observer.observe(root);
|
|
277
|
+
for (const event of ['resize', 'orientationchange', 'pageshow']) listen(window, event, scheduleResize, { passive: true });
|
|
278
|
+
for (const event of ['resize', 'scroll']) if (window.visualViewport) listen(window.visualViewport, event, scheduleResize, { passive: true });
|
|
279
|
+
listen(document, 'visibilitychange', () => { stopLoop(); startLoop(); });
|
|
280
|
+
listen(window, 'pagehide', stopLoop);
|
|
281
|
+
listen(window, 'pageshow', startLoop);
|
|
282
|
+
for (const event of ['gesturestart', 'gesturechange', 'gestureend', 'contextmenu', 'dragstart']) listen(stage, event, (e) => e.preventDefault(), { passive: false });
|
|
283
|
+
listen(stage, 'touchmove', (event) => { if (event.touches.length > 1) event.preventDefault(); }, { passive: false });
|
|
284
|
+
listen(renderer.domElement, 'webglcontextlost', (event) => {
|
|
285
|
+
event.preventDefault(); contextLost = true; stopLoop();
|
|
286
|
+
});
|
|
287
|
+
listen(renderer.domElement, 'webglcontextrestored', () => {
|
|
288
|
+
contextLost = false; resize(); startLoop();
|
|
289
|
+
});
|
|
290
|
+
const raycaster = new THREE.Raycaster();
|
|
291
|
+
const pointer = new THREE.Vector2();
|
|
292
|
+
for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']) listen(renderer.domElement, type, (event) => {
|
|
293
|
+
if (status !== 'playing') return;
|
|
294
|
+
if (type === 'pointerdown') renderer.domElement.setPointerCapture(event.pointerId);
|
|
295
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
296
|
+
pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
297
|
+
current.camera.updateMatrixWorld(); current.scene.updateMatrixWorld(true);
|
|
298
|
+
raycaster.setFromCamera(pointer, current.camera);
|
|
299
|
+
try {
|
|
300
|
+
current.hooks?.pointer?.({ type, originalEvent: event, point: pointer.clone(), raycaster, hits: (objects = current.scene.children) => raycaster.intersectObjects(objects, true) });
|
|
301
|
+
} catch (error) { report(error); }
|
|
302
|
+
});
|
|
303
|
+
resize();
|
|
304
|
+
// The capture tool uses the same runtime and setup code as the interactive game.
|
|
305
|
+
window.__MESHPAD__ = game;
|
|
306
|
+
// Let the entry module finish initializing classes and constants. Explicit loads
|
|
307
|
+
// or disposal before this microtask supersede the deferred initial load.
|
|
308
|
+
game.ready = Promise.resolve().then(() => !disposed && generation === 0 ? loadLevel(initial) : false);
|
|
309
|
+
game.ready.catch(() => {});
|
|
310
|
+
return game;
|
|
311
|
+
}
|