@bornengine/engine 0.4.16
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/LICENSE +21 -0
- package/README.md +231 -0
- package/native/android/Cargo.lock +1848 -0
- package/native/android/Cargo.toml +24 -0
- package/native/android/src/lib.rs +702 -0
- package/native/ios/Cargo.lock +1690 -0
- package/native/ios/Cargo.toml +32 -0
- package/native/ios/src/lib.rs +1267 -0
- package/native/linux/Cargo.lock +3279 -0
- package/native/linux/Cargo.toml +29 -0
- package/native/linux/src/lib.rs +1331 -0
- package/native/macos/Cargo.lock +3310 -0
- package/native/macos/Cargo.toml +46 -0
- package/native/macos/src/lib.rs +1302 -0
- package/native/shared/Cargo.lock +1899 -0
- package/native/shared/Cargo.toml +62 -0
- package/native/shared/assets/default_font.ttf +0 -0
- package/native/shared/build.rs +270 -0
- package/native/shared/shaders/common/clouds.wgsl +122 -0
- package/native/shared/shaders/common/fog.wgsl +16 -0
- package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
- package/native/shared/shaders/common/imposter.wgsl +112 -0
- package/native/shared/shaders/common/pbr.wgsl +186 -0
- package/native/shared/shaders/common/shadows.wgsl +186 -0
- package/native/shared/shaders/common/sky.wgsl +8 -0
- package/native/shared/shaders/common/tonemap.wgsl +25 -0
- package/native/shared/shaders/impulse_field.wgsl +57 -0
- package/native/shared/shaders/material_abi.wgsl +383 -0
- package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
- package/native/shared/src/anim_mixer.rs +61 -0
- package/native/shared/src/attach.rs +263 -0
- package/native/shared/src/audio/decode.rs +123 -0
- package/native/shared/src/audio/mod.rs +863 -0
- package/native/shared/src/audio/render.rs +892 -0
- package/native/shared/src/audio/spsc.rs +156 -0
- package/native/shared/src/audio/stream.rs +226 -0
- package/native/shared/src/custom_shaders.rs +104 -0
- package/native/shared/src/decals.rs +245 -0
- package/native/shared/src/drs.rs +211 -0
- package/native/shared/src/engine.rs +261 -0
- package/native/shared/src/ffi.rs +116 -0
- package/native/shared/src/ffi_core/assets.rs +388 -0
- package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
- package/native/shared/src/ffi_core/draw.rs +334 -0
- package/native/shared/src/ffi_core/game_loop.rs +577 -0
- package/native/shared/src/ffi_core/input.rs +234 -0
- package/native/shared/src/ffi_core/mod.rs +127 -0
- package/native/shared/src/ffi_core/models.rs +1154 -0
- package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
- package/native/shared/src/ffi_core/scene.rs +626 -0
- package/native/shared/src/ffi_core/vfx.rs +212 -0
- package/native/shared/src/ffi_core/visual.rs +691 -0
- package/native/shared/src/frame_callbacks.rs +122 -0
- package/native/shared/src/geometry.rs +236 -0
- package/native/shared/src/handles.rs +182 -0
- package/native/shared/src/input.rs +448 -0
- package/native/shared/src/jolt_sys.rs +822 -0
- package/native/shared/src/lib.rs +55 -0
- package/native/shared/src/models.rs +1093 -0
- package/native/shared/src/models_gltf.rs +1280 -0
- package/native/shared/src/particles.rs +391 -0
- package/native/shared/src/physics_jolt.rs +1908 -0
- package/native/shared/src/picking.rs +298 -0
- package/native/shared/src/postfx.rs +345 -0
- package/native/shared/src/profiler.rs +492 -0
- package/native/shared/src/ragdoll.rs +474 -0
- package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
- package/native/shared/src/renderer/brdf_lut.rs +154 -0
- package/native/shared/src/renderer/draw2d.rs +143 -0
- package/native/shared/src/renderer/formats.rs +822 -0
- package/native/shared/src/renderer/froxel.rs +421 -0
- package/native/shared/src/renderer/gi_bake.rs +653 -0
- package/native/shared/src/renderer/graph.rs +462 -0
- package/native/shared/src/renderer/hiz.rs +269 -0
- package/native/shared/src/renderer/hot_reload.rs +390 -0
- package/native/shared/src/renderer/impulse_field.rs +456 -0
- package/native/shared/src/renderer/lighting.rs +154 -0
- package/native/shared/src/renderer/material_instancing.rs +171 -0
- package/native/shared/src/renderer/material_pipeline.rs +700 -0
- package/native/shared/src/renderer/material_system.rs +1996 -0
- package/native/shared/src/renderer/material_system_tests.rs +601 -0
- package/native/shared/src/renderer/material_system_wasm.rs +41 -0
- package/native/shared/src/renderer/mod.rs +12556 -0
- package/native/shared/src/renderer/model_draw.rs +641 -0
- package/native/shared/src/renderer/occlusion.rs +429 -0
- package/native/shared/src/renderer/planar_pass.rs +593 -0
- package/native/shared/src/renderer/planar_reflection.rs +499 -0
- package/native/shared/src/renderer/post_pass.rs +249 -0
- package/native/shared/src/renderer/postfx_chain.rs +728 -0
- package/native/shared/src/renderer/pt_pass.rs +577 -0
- package/native/shared/src/renderer/scene_pass.rs +607 -0
- package/native/shared/src/renderer/shader_include.rs +205 -0
- package/native/shared/src/renderer/shader_library.rs +135 -0
- package/native/shared/src/renderer/shaders/ao.rs +570 -0
- package/native/shared/src/renderer/shaders/core.rs +1243 -0
- package/native/shared/src/renderer/shaders/env.rs +907 -0
- package/native/shared/src/renderer/shaders/gi.rs +810 -0
- package/native/shared/src/renderer/shaders/mod.rs +19 -0
- package/native/shared/src/renderer/shaders/post.rs +1558 -0
- package/native/shared/src/renderer/shaders/pt.rs +1859 -0
- package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
- package/native/shared/src/renderer/shadow_pass.rs +731 -0
- package/native/shared/src/renderer/ssgi_pass.rs +392 -0
- package/native/shared/src/renderer/ssr_pass.rs +188 -0
- package/native/shared/src/renderer/texture_store.rs +473 -0
- package/native/shared/src/renderer/transient.rs +591 -0
- package/native/shared/src/renderer/types.rs +941 -0
- package/native/shared/src/renderer/util.rs +152 -0
- package/native/shared/src/scene.rs +1362 -0
- package/native/shared/src/sdf_cache.rs +274 -0
- package/native/shared/src/shadows.rs +1036 -0
- package/native/shared/src/staging.rs +102 -0
- package/native/shared/src/string_header.rs +266 -0
- package/native/shared/src/text_renderer.rs +502 -0
- package/native/shared/src/textures.rs +197 -0
- package/native/tvos/Cargo.lock +1693 -0
- package/native/tvos/Cargo.toml +36 -0
- package/native/tvos/metal-patched/Cargo.toml +178 -0
- package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
- package/native/tvos/metal-patched/LICENSE-MIT +25 -0
- package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
- package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
- package/native/tvos/metal-patched/src/argument.rs +366 -0
- package/native/tvos/metal-patched/src/blitpass.rs +102 -0
- package/native/tvos/metal-patched/src/buffer.rs +71 -0
- package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
- package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
- package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
- package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
- package/native/tvos/metal-patched/src/computepass.rs +107 -0
- package/native/tvos/metal-patched/src/constants.rs +152 -0
- package/native/tvos/metal-patched/src/counters.rs +119 -0
- package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
- package/native/tvos/metal-patched/src/device.rs +2134 -0
- package/native/tvos/metal-patched/src/drawable.rs +39 -0
- package/native/tvos/metal-patched/src/encoder.rs +2041 -0
- package/native/tvos/metal-patched/src/heap.rs +281 -0
- package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
- package/native/tvos/metal-patched/src/lib.rs +657 -0
- package/native/tvos/metal-patched/src/library.rs +902 -0
- package/native/tvos/metal-patched/src/mps.rs +575 -0
- package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
- package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
- package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
- package/native/tvos/metal-patched/src/renderpass.rs +443 -0
- package/native/tvos/metal-patched/src/resource.rs +182 -0
- package/native/tvos/metal-patched/src/sampler.rs +165 -0
- package/native/tvos/metal-patched/src/sync.rs +178 -0
- package/native/tvos/metal-patched/src/texture.rs +352 -0
- package/native/tvos/metal-patched/src/types.rs +90 -0
- package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
- package/native/tvos/src/audio_backend.rs +197 -0
- package/native/tvos/src/lib.rs +1891 -0
- package/native/visionos/Cargo.lock +1693 -0
- package/native/visionos/Cargo.toml +40 -0
- package/native/visionos/src/audio_backend.rs +197 -0
- package/native/visionos/src/lib.rs +1887 -0
- package/native/watchos/Cargo.lock +16 -0
- package/native/watchos/Cargo.toml +19 -0
- package/native/watchos/shaders/bloom_postfx.metal +99 -0
- package/native/watchos/src/BloomWatchApp.swift +1267 -0
- package/native/watchos/src/BloomWatchAudio.swift +179 -0
- package/native/watchos/src/audio.rs +55 -0
- package/native/watchos/src/draw_list.rs +229 -0
- package/native/watchos/src/ffi_stubs.rs +915 -0
- package/native/watchos/src/ffi_stubs_manual.rs +35 -0
- package/native/watchos/src/lib.rs +1124 -0
- package/native/watchos/src/models.rs +746 -0
- package/native/watchos/src/postfx.rs +95 -0
- package/native/watchos/src/scene.rs +534 -0
- package/native/watchos/src/textures.rs +184 -0
- package/native/web/Cargo.lock +1657 -0
- package/native/web/Cargo.toml +43 -0
- package/native/web/bloom_glue.js +695 -0
- package/native/web/build.sh +131 -0
- package/native/web/index.html +35 -0
- package/native/web/jolt_bridge.js +1519 -0
- package/native/web/src/input_ffi.rs +286 -0
- package/native/web/src/lib.rs +1796 -0
- package/native/web/src/material_ffi.rs +710 -0
- package/native/web/src/parity_ffi.rs +343 -0
- package/native/web/src/physics_ffi.rs +643 -0
- package/native/web/src/ragdoll_ffi.rs +250 -0
- package/native/web/src/render_settings.rs +98 -0
- package/native/windows/Cargo.lock +1815 -0
- package/native/windows/Cargo.toml +68 -0
- package/native/windows/src/lib.rs +1486 -0
- package/package.json +4279 -0
- package/src/audio/index.ts +315 -0
- package/src/core/colors.ts +63 -0
- package/src/core/index.ts +1206 -0
- package/src/core/keys.ts +63 -0
- package/src/core/types.ts +104 -0
- package/src/index.ts +171 -0
- package/src/math/index.ts +516 -0
- package/src/mobile/index.ts +294 -0
- package/src/models/index.ts +1258 -0
- package/src/physics/index.ts +1134 -0
- package/src/scene/index.ts +698 -0
- package/src/shapes/index.ts +120 -0
- package/src/text/index.ts +48 -0
- package/src/textures/index.ts +187 -0
- package/src/vfx/index.ts +191 -0
- package/src/world/index.ts +24 -0
- package/src/world/loader.ts +423 -0
- package/src/world/prefab.ts +217 -0
- package/src/world/render.ts +172 -0
- package/src/world/saver.ts +108 -0
- package/src/world/serialize.ts +301 -0
- package/src/world/terrain.ts +355 -0
- package/src/world/types.ts +160 -0
- package/src/world/validate.ts +319 -0
- package/src/world/version.ts +114 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bloom Engine — Web Bootstrap & FFI Bridge
|
|
3
|
+
*
|
|
4
|
+
* Loaded as a deferred ES module by both the standalone template (`index.html`,
|
|
5
|
+
* engine-only / no game) and the build-assembled page that `build.sh` produces
|
|
6
|
+
* by splicing this bootstrap into Perry's self-contained WASM HTML.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* 1. Load the Bloom rendering engine WASM (`pkg/bloom_web.js`, wasm-pack).
|
|
10
|
+
* 2. Initialise JoltPhysics.js (best-effort; local vendored copy, then CDN).
|
|
11
|
+
* 3. Prefetch the game's asset tree (assets_manifest.json, if the build
|
|
12
|
+
* emitted one) with a progress readout, so the game's synchronous loads
|
|
13
|
+
* hit a warm cache instead of issuing one blocking XHR per file.
|
|
14
|
+
* 4. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry
|
|
15
|
+
* game WASM imports under its `"ffi"` namespace.
|
|
16
|
+
* 5. Pre-initialise the wgpu window and wait for the async device setup,
|
|
17
|
+
* so the game's synchronous `main()` never races "Engine not
|
|
18
|
+
* initialized" (Perry main is sync; wgpu init is async).
|
|
19
|
+
* 6. Wire DOM input (keyboard/mouse/touch/pointer-lock), HiDPI canvas
|
|
20
|
+
* sizing, Web Audio, and the rAF game loop.
|
|
21
|
+
* 7. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call
|
|
22
|
+
* in the page can instantiate the game WASM *after* all of the above.
|
|
23
|
+
*
|
|
24
|
+
* --- The FFI value contract (important) ---
|
|
25
|
+
* Perry's WASM runtime (`wasm_runtime.js`, embedded in the page) wraps the
|
|
26
|
+
* entire `ffi` namespace with `wrapFfiForI64`: it DECODES each NaN-boxed i64
|
|
27
|
+
* argument to a plain JS value (number / string / handle) before calling us,
|
|
28
|
+
* and RE-ENCODES our plain return value. So every function below receives and
|
|
29
|
+
* returns ordinary JS values — there is no manual NaN-boxing to do here. A
|
|
30
|
+
* `bloom_draw_text(text, ...)` import arrives with `text` already a JS string.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import init, * as bloom from './pkg/bloom_web.js';
|
|
34
|
+
|
|
35
|
+
let bloomModule = null;
|
|
36
|
+
let booted = false;
|
|
37
|
+
|
|
38
|
+
// --- Game loop state ---
|
|
39
|
+
let gameCallback = null; // Perry closure handle captured from bloom_run_game
|
|
40
|
+
let gameRunning = false;
|
|
41
|
+
let rafId = null;
|
|
42
|
+
|
|
43
|
+
// --- Asset prefetch cache ---
|
|
44
|
+
// path → Uint8Array. Filled from assets_manifest.json before the game boots;
|
|
45
|
+
// syncFetchBytes serves from it and only falls back to a blocking XHR for
|
|
46
|
+
// paths the manifest didn't know about.
|
|
47
|
+
const assetCache = new Map();
|
|
48
|
+
let manifestPaths = null; // Set<string> when a manifest was loaded
|
|
49
|
+
|
|
50
|
+
// --- Pointer lock state ---
|
|
51
|
+
// The game expresses intent through bloom_disable_cursor/enable_cursor; the
|
|
52
|
+
// browser only grants pointer lock inside a user gesture, and revokes it on
|
|
53
|
+
// ESC without delivering the keydown. Both mismatches are reconciled here.
|
|
54
|
+
let wantPointerLock = false;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Idempotent engine bootstrap. Safe to call multiple times; only the first
|
|
58
|
+
* call performs work. Returns the resolved Bloom wasm-bindgen module.
|
|
59
|
+
*/
|
|
60
|
+
export async function bootBloomGame() {
|
|
61
|
+
if (booted) return bloomModule;
|
|
62
|
+
booted = true;
|
|
63
|
+
|
|
64
|
+
const loading = document.getElementById('loading');
|
|
65
|
+
const setStatus = (text) => { if (loading) loading.textContent = text; };
|
|
66
|
+
|
|
67
|
+
// 1. Load Bloom engine WASM.
|
|
68
|
+
setStatus('Loading engine…');
|
|
69
|
+
await init();
|
|
70
|
+
bloomModule = bloom;
|
|
71
|
+
|
|
72
|
+
// 2. Initialise Jolt physics (WASM build of Jolt). The jolt_bridge.js module
|
|
73
|
+
// wasm-bindgen bundled into pkg/snippets/ owns all physics state; we reach
|
|
74
|
+
// it via the Rust-exported helper so bloom_physics_* talk to one instance.
|
|
75
|
+
// Resolution order: explicit override, a vendored local copy (offline
|
|
76
|
+
// builds), then the CDN.
|
|
77
|
+
try {
|
|
78
|
+
let factory = globalThis.__joltFactory;
|
|
79
|
+
if (!factory) {
|
|
80
|
+
try {
|
|
81
|
+
factory = (await import(new URL('./jolt-physics.mjs', import.meta.url))).default;
|
|
82
|
+
} catch {
|
|
83
|
+
factory = (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await bloom.bloom_physics_init_jolt(factory);
|
|
87
|
+
console.log('[bloom] Jolt physics ready');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn('[bloom] Jolt init failed:', e, '- bloom_physics_* calls will be no-ops');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Prefetch the asset tree, if the build emitted a manifest. Progress is
|
|
93
|
+
// the one thing a frozen synchronous boot cannot show, so it is shown
|
|
94
|
+
// HERE, before the game WASM gets control.
|
|
95
|
+
await prefetchAssets(setStatus);
|
|
96
|
+
|
|
97
|
+
// 4. Publish the FFI surface for the Perry game WASM.
|
|
98
|
+
const ffi = buildFfiImports();
|
|
99
|
+
if (typeof globalThis.__ffiImports === 'undefined') {
|
|
100
|
+
globalThis.__ffiImports = ffi;
|
|
101
|
+
} else {
|
|
102
|
+
Object.assign(globalThis.__ffiImports, ffi);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5. DOM wiring (input + HiDPI canvas sizing + audio unlock).
|
|
106
|
+
setupDomBridge();
|
|
107
|
+
|
|
108
|
+
// 6. Pre-initialise the window and WAIT for the async wgpu setup. Perry's
|
|
109
|
+
// main() is synchronous and calls engine functions immediately after its
|
|
110
|
+
// own initWindow (which is an idempotent no-op once this ran) — booting
|
|
111
|
+
// the game before the device exists panics with "Engine not initialized".
|
|
112
|
+
setStatus('Starting renderer…');
|
|
113
|
+
// Probe for an actual WebGPU adapter first: having the API surface
|
|
114
|
+
// (navigator.gpu) is not having an adapter — Chrome ships the API even
|
|
115
|
+
// when acceleration is off or the GPU is blocklisted, and wgpu would then
|
|
116
|
+
// panic inside the wasm. Fail here with an actionable message instead.
|
|
117
|
+
if (!navigator.gpu) {
|
|
118
|
+
throw new Error('This browser has no WebGPU support. Use a current '
|
|
119
|
+
+ 'Chrome/Edge (113+) or Firefox (141+).');
|
|
120
|
+
}
|
|
121
|
+
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' })
|
|
122
|
+
.catch(() => null);
|
|
123
|
+
if (!adapter) {
|
|
124
|
+
throw new Error('No WebGPU adapter available. Check chrome://gpu — '
|
|
125
|
+
+ 'hardware acceleration must be enabled and the GPU not blocklisted.');
|
|
126
|
+
}
|
|
127
|
+
const canvas = document.getElementById('bloom-canvas');
|
|
128
|
+
const w = (canvas && canvas.clientWidth) || window.innerWidth || 1280;
|
|
129
|
+
const h = (canvas && canvas.clientHeight) || window.innerHeight || 720;
|
|
130
|
+
bloom.bloom_init_window(w, h, 0, 0);
|
|
131
|
+
const deadline = performance.now() + 15000;
|
|
132
|
+
while (bloom.bloom_is_initialized() < 0.5) {
|
|
133
|
+
if (performance.now() > deadline) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'WebGPU device setup timed out. Check chrome://gpu — a WebGPU adapter '
|
|
136
|
+
+ 'is required (hardware acceleration on, GPU not blocklisted).');
|
|
137
|
+
}
|
|
138
|
+
await new Promise((r) => setTimeout(r, 16));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 7. Hand over to the game. Keep the loading indicator up — the game's own
|
|
142
|
+
// synchronous boot (asset decode, pool init) runs next; remove it on the
|
|
143
|
+
// first real frame instead.
|
|
144
|
+
setStatus('Starting game…');
|
|
145
|
+
console.log('[bloom] engine + FFI ready');
|
|
146
|
+
return bloomModule;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Fetch assets_manifest.json and warm the cache, 8 files at a time. */
|
|
150
|
+
async function prefetchAssets(setStatus) {
|
|
151
|
+
let manifest = null;
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch('assets_manifest.json');
|
|
154
|
+
if (res.ok) manifest = await res.json();
|
|
155
|
+
} catch { /* no manifest = per-file sync XHR, same as before */ }
|
|
156
|
+
if (!manifest || !Array.isArray(manifest.files)) return;
|
|
157
|
+
|
|
158
|
+
manifestPaths = new Set(manifest.files);
|
|
159
|
+
const queue = manifest.files.slice();
|
|
160
|
+
const total = queue.length;
|
|
161
|
+
let done = 0;
|
|
162
|
+
setStatus(`Loading assets… 0/${total}`);
|
|
163
|
+
|
|
164
|
+
async function worker() {
|
|
165
|
+
for (;;) {
|
|
166
|
+
const path = queue.shift();
|
|
167
|
+
if (path === undefined) return;
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(path);
|
|
170
|
+
if (res.ok) assetCache.set(path, new Uint8Array(await res.arrayBuffer()));
|
|
171
|
+
} catch { /* miss -> the sync XHR fallback will report it */ }
|
|
172
|
+
done++;
|
|
173
|
+
if (done % 5 === 0 || done === total) setStatus(`Loading assets… ${done}/${total}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const workers = [];
|
|
177
|
+
for (let i = 0; i < 8; i++) workers.push(worker());
|
|
178
|
+
await Promise.all(workers);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the `bloom_*` FFI import object. Defaults pass straight through to the
|
|
183
|
+
* matching wasm-bindgen export (correct for all numeric-in/out and string-out
|
|
184
|
+
* functions); the overrides below handle string-in, file loading, the host
|
|
185
|
+
* surfaces (window/title/audio/fullscreen/cursor/storage), and the game loop.
|
|
186
|
+
*/
|
|
187
|
+
function buildFfiImports() {
|
|
188
|
+
const imports = {};
|
|
189
|
+
|
|
190
|
+
// Default: every bloom_* export, passed plain values by Perry's wrapFfiForI64.
|
|
191
|
+
for (const [name, fn] of Object.entries(bloom)) {
|
|
192
|
+
if (typeof fn === 'function' && name.startsWith('bloom_')) {
|
|
193
|
+
imports[name] = fn;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Text: string params route to the _str variants ---
|
|
198
|
+
imports.bloom_draw_text = (text, x, y, size, r, g, b, a) =>
|
|
199
|
+
bloom.bloom_draw_text_str(String(text), x, y, size, r, g, b, a);
|
|
200
|
+
imports.bloom_draw_text_ex = (font, text, x, y, size, spacing, r, g, b, a) =>
|
|
201
|
+
bloom.bloom_draw_text_ex_str(font, String(text), x, y, size, spacing, r, g, b, a);
|
|
202
|
+
imports.bloom_measure_text = (text, size) =>
|
|
203
|
+
bloom.bloom_measure_text_str(String(text), size);
|
|
204
|
+
imports.bloom_measure_text_ex = (font, text, size, spacing) =>
|
|
205
|
+
bloom.bloom_measure_text_ex_str(font, String(text), size, spacing);
|
|
206
|
+
|
|
207
|
+
// --- Materials & post-FX: shader source strings route to _str variants ---
|
|
208
|
+
const materialVariants = [
|
|
209
|
+
'bloom_compile_material', 'bloom_compile_material_refractive',
|
|
210
|
+
'bloom_compile_material_transparent', 'bloom_compile_material_additive',
|
|
211
|
+
'bloom_compile_material_cutout', 'bloom_compile_material_instanced',
|
|
212
|
+
'bloom_add_post_pass', 'bloom_set_post_pass',
|
|
213
|
+
];
|
|
214
|
+
for (const name of materialVariants) {
|
|
215
|
+
const strFn = bloom[name + '_str'];
|
|
216
|
+
if (strFn) imports[name] = (source) => strFn(String(source));
|
|
217
|
+
}
|
|
218
|
+
imports.bloom_compile_material_instanced_bucket = (source, bucket, readsScene) =>
|
|
219
|
+
bloom.bloom_compile_material_instanced_bucket(String(source), bucket, readsScene);
|
|
220
|
+
|
|
221
|
+
// compile_material_from_file: no web filesystem — fetch the source and
|
|
222
|
+
// compile it into the requested bucket (0 opaque | 1 transparent |
|
|
223
|
+
// 2 refractive | 3 additive | 4 cutout — the TS wrapper's encoding).
|
|
224
|
+
imports.bloom_compile_material_from_file = (path, bucketKind) => {
|
|
225
|
+
const src = syncFetchText(String(path));
|
|
226
|
+
if (src == null) return 0;
|
|
227
|
+
switch (bucketKind | 0) {
|
|
228
|
+
case 1: return bloom.bloom_compile_material_transparent_str(src);
|
|
229
|
+
case 2: return bloom.bloom_compile_material_refractive_str(src);
|
|
230
|
+
case 3: return bloom.bloom_compile_material_additive_str(src);
|
|
231
|
+
case 4: return bloom.bloom_compile_material_cutout_str(src);
|
|
232
|
+
default: return bloom.bloom_compile_material_str(src);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Material params via the legacy pointer signature: Perry's wrapFfiForI64
|
|
237
|
+
// hands us the array as a plain JS array, so route it to the floats entry.
|
|
238
|
+
imports.bloom_set_material_params = (handle, params, _count) => {
|
|
239
|
+
if (params && typeof params.length === 'number') {
|
|
240
|
+
bloom.bloom_set_material_params_floats(handle, Float32Array.from(params));
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// --- Asset loading: fetch the file synchronously, hand bytes to _bytes ---
|
|
245
|
+
const byteLoaders = {
|
|
246
|
+
bloom_load_texture: 'bloom_load_texture_bytes',
|
|
247
|
+
bloom_load_font: 'bloom_load_font_bytes',
|
|
248
|
+
bloom_load_sound: 'bloom_load_sound_bytes',
|
|
249
|
+
bloom_load_music: 'bloom_load_music_bytes',
|
|
250
|
+
bloom_load_model: 'bloom_load_model_bytes',
|
|
251
|
+
bloom_load_model_animation: 'bloom_load_model_animation_bytes',
|
|
252
|
+
bloom_load_image: 'bloom_load_image_bytes',
|
|
253
|
+
bloom_stage_model: 'bloom_stage_model_bytes',
|
|
254
|
+
bloom_set_env_clear_from_hdr: 'bloom_set_env_clear_from_hdr_bytes',
|
|
255
|
+
};
|
|
256
|
+
for (const [name, bytesName] of Object.entries(byteLoaders)) {
|
|
257
|
+
const bytesFn = bloom[bytesName];
|
|
258
|
+
if (!bytesFn) continue;
|
|
259
|
+
imports[name] = (path) => {
|
|
260
|
+
const data = syncFetchBytes(String(path));
|
|
261
|
+
return data ? bytesFn(data) : 0;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Texture array from a comma-separated path list: fetch each file's bytes,
|
|
266
|
+
// decode + assemble engine-side (same codecs as the native from-files FFI).
|
|
267
|
+
imports.bloom_create_texture_array_from_files = (paths, format, mipLevels) => {
|
|
268
|
+
if (!bloom.bloom_texture_array_files_push) return 0;
|
|
269
|
+
bloom.bloom_texture_array_files_reset();
|
|
270
|
+
for (const raw of String(paths).split(',')) {
|
|
271
|
+
const p = raw.trim();
|
|
272
|
+
if (!p) continue;
|
|
273
|
+
const data = syncFetchBytes(p);
|
|
274
|
+
if (data) bloom.bloom_texture_array_files_push(data);
|
|
275
|
+
else console.warn('[texarray] missing layer file:', p);
|
|
276
|
+
}
|
|
277
|
+
return bloom.bloom_texture_array_files_commit(format, mipLevels);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// --- Window / title ---
|
|
281
|
+
imports.bloom_init_window = (w, h, title, fullscreen) => {
|
|
282
|
+
document.title = String(title) || 'Bloom Engine';
|
|
283
|
+
// Web export ignores the title slot (_title: f64); pass 0 for it.
|
|
284
|
+
bloom.bloom_init_window(w, h, 0, fullscreen);
|
|
285
|
+
};
|
|
286
|
+
imports.bloom_set_window_title = (title) => { document.title = String(title); };
|
|
287
|
+
|
|
288
|
+
// --- Web Audio ---
|
|
289
|
+
imports.bloom_init_audio = () => initAudioBridge();
|
|
290
|
+
imports.bloom_close_audio = () => closeAudioBridge();
|
|
291
|
+
|
|
292
|
+
// --- Fullscreen / cursor ---
|
|
293
|
+
imports.bloom_toggle_fullscreen = () => {
|
|
294
|
+
const canvas = document.getElementById('bloom-canvas');
|
|
295
|
+
if (!document.fullscreenElement) canvas?.requestFullscreen?.().catch(() => {});
|
|
296
|
+
else document.exitFullscreen().catch(() => {});
|
|
297
|
+
};
|
|
298
|
+
imports.bloom_disable_cursor = () => {
|
|
299
|
+
wantPointerLock = true;
|
|
300
|
+
document.getElementById('bloom-canvas')?.requestPointerLock?.();
|
|
301
|
+
bloom.bloom_disable_cursor();
|
|
302
|
+
};
|
|
303
|
+
imports.bloom_enable_cursor = () => {
|
|
304
|
+
wantPointerLock = false;
|
|
305
|
+
document.exitPointerLock?.();
|
|
306
|
+
bloom.bloom_enable_cursor();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// --- File I/O: localStorage first (saves/settings), then the served
|
|
310
|
+
// asset tree (read-only data files like world JSON and manifests) ---
|
|
311
|
+
const LS_PREFIX = 'bloom_fs:';
|
|
312
|
+
imports.bloom_write_file = (path, data) => {
|
|
313
|
+
try { localStorage.setItem(LS_PREFIX + String(path), String(data)); return 1; }
|
|
314
|
+
catch { return 0; }
|
|
315
|
+
};
|
|
316
|
+
imports.bloom_file_exists = (path) => {
|
|
317
|
+
const p = String(path);
|
|
318
|
+
if (localStorage.getItem(LS_PREFIX + p) !== null) return 1;
|
|
319
|
+
if (manifestPaths) return manifestPaths.has(p) ? 1 : 0;
|
|
320
|
+
return syncFetchBytes(p) !== null ? 1 : 0;
|
|
321
|
+
};
|
|
322
|
+
imports.bloom_read_file = (path) => {
|
|
323
|
+
const p = String(path);
|
|
324
|
+
const v = localStorage.getItem(LS_PREFIX + p);
|
|
325
|
+
if (v !== null) return v;
|
|
326
|
+
const text = syncFetchText(p);
|
|
327
|
+
return text === null ? '' : text; // plain string; Perry re-encodes via wrapFfiForI64
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// --- Game loop ---
|
|
331
|
+
// runGame() on web (bloom_get_platform() === 7) just hands its update closure
|
|
332
|
+
// to bloom_run_game and returns; the blocking native loop is never entered.
|
|
333
|
+
// We capture the closure and drive it from requestAnimationFrame.
|
|
334
|
+
imports.bloom_run_game = (callback) => {
|
|
335
|
+
gameCallback = callback;
|
|
336
|
+
if (!gameRunning) {
|
|
337
|
+
gameRunning = true;
|
|
338
|
+
startRafLoop();
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
// Safety net for a game that still spins `while (!windowShouldClose())`:
|
|
342
|
+
// report "should close" once the rAF loop owns frame pacing, so the stray
|
|
343
|
+
// loop exits after one iteration instead of hanging the tab.
|
|
344
|
+
imports.bloom_window_should_close = () => (gameRunning ? 1 : 0);
|
|
345
|
+
imports.bloom_close_window = () => stopGame();
|
|
346
|
+
|
|
347
|
+
// Last: wrap every entry so (a) a throw names the FFI call that produced
|
|
348
|
+
// it — Perry's boot catch reports only the message, which for an ABI
|
|
349
|
+
// mismatch reads like a riddle — and (b) BigInt args (manifest-declared
|
|
350
|
+
// i64 params, e.g. pointer slots) are coerced to Numbers, since every
|
|
351
|
+
// implementation here is a plain JS/wasm-bindgen function expecting f64.
|
|
352
|
+
for (const [name, fn] of Object.entries(imports)) {
|
|
353
|
+
imports[name] = (...args) => {
|
|
354
|
+
for (let i = 0; i < args.length; i++) {
|
|
355
|
+
if (typeof args[i] === 'bigint') args[i] = Number(args[i]);
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
return fn(...args);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
console.error('[bloom ffi]', name, 'threw:', e, e?.stack ?? '');
|
|
361
|
+
throw e;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return imports;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Drive the captured Perry game closure once per animation frame:
|
|
371
|
+
* flush queued input → begin_drawing → callback(dt) → end_drawing.
|
|
372
|
+
*
|
|
373
|
+
* The closure is invoked through Perry's `callWasmClosure`, a global helper its
|
|
374
|
+
* runtime exposes that resolves the closure's function-table index + captures
|
|
375
|
+
* against the live game WASM instance. By the time the first frame runs, the
|
|
376
|
+
* runtime classic <script> has executed, so the global is defined.
|
|
377
|
+
*/
|
|
378
|
+
function startRafLoop() {
|
|
379
|
+
let firstFrame = true;
|
|
380
|
+
function frame() {
|
|
381
|
+
if (!gameRunning) return;
|
|
382
|
+
if (firstFrame) {
|
|
383
|
+
firstFrame = false;
|
|
384
|
+
document.getElementById('loading')?.remove();
|
|
385
|
+
}
|
|
386
|
+
flushInput();
|
|
387
|
+
bloom.bloom_begin_drawing();
|
|
388
|
+
if (gameCallback !== null && typeof globalThis.callWasmClosure === 'function') {
|
|
389
|
+
const dt = bloom.bloom_get_delta_time();
|
|
390
|
+
try {
|
|
391
|
+
globalThis.callWasmClosure(gameCallback, dt);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
console.error('[bloom] game frame threw; stopping loop:', e);
|
|
394
|
+
gameRunning = false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
bloom.bloom_end_drawing();
|
|
398
|
+
rafId = requestAnimationFrame(frame);
|
|
399
|
+
}
|
|
400
|
+
rafId = requestAnimationFrame(frame);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Stop the rAF loop. */
|
|
404
|
+
export function stopGame() {
|
|
405
|
+
gameRunning = false;
|
|
406
|
+
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Synchronous asset fetching ---
|
|
410
|
+
// Perry calls load functions synchronously (like native). The prefetch cache
|
|
411
|
+
// (assets_manifest.json) makes the common case a Map lookup; anything else
|
|
412
|
+
// blocks on a sync XHR, which is fine for startup-time same-origin loads.
|
|
413
|
+
const textCache = new Map();
|
|
414
|
+
function syncFetchBytes(url) {
|
|
415
|
+
const cached = assetCache.get(url);
|
|
416
|
+
if (cached !== undefined) return cached;
|
|
417
|
+
// A manifest is authoritative for the asset tree: paths it doesn't list
|
|
418
|
+
// are treated as absent without a blocking round-trip (level probes and
|
|
419
|
+
// optional-sound checks would otherwise stall boot by one XHR each).
|
|
420
|
+
if (manifestPaths && url.indexOf('://') < 0 && !manifestPaths.has(url)) return null;
|
|
421
|
+
try {
|
|
422
|
+
const xhr = new XMLHttpRequest();
|
|
423
|
+
xhr.open('GET', url, false);
|
|
424
|
+
xhr.responseType = 'arraybuffer';
|
|
425
|
+
xhr.send();
|
|
426
|
+
if (xhr.status === 200 || xhr.status === 0) {
|
|
427
|
+
const bytes = new Uint8Array(xhr.response);
|
|
428
|
+
assetCache.set(url, bytes);
|
|
429
|
+
return bytes;
|
|
430
|
+
}
|
|
431
|
+
} catch (e) {
|
|
432
|
+
console.warn('[bloom] syncFetchBytes failed for', url, e);
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
function syncFetchText(url) {
|
|
437
|
+
const hit = textCache.get(url);
|
|
438
|
+
if (hit !== undefined) return hit;
|
|
439
|
+
const bytes = syncFetchBytes(url);
|
|
440
|
+
const text = bytes ? new TextDecoder().decode(bytes) : null;
|
|
441
|
+
textCache.set(url, text);
|
|
442
|
+
return text;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- Web Audio bridge ---
|
|
446
|
+
// The Rust mixer is pulled by a ScriptProcessorNode; creation is attempted at
|
|
447
|
+
// bloom_init_audio, but browsers gate (or suspend) an AudioContext until a
|
|
448
|
+
// user gesture, so the first pointerdown/keydown resumes it.
|
|
449
|
+
let audioContext = null;
|
|
450
|
+
let audioProcessor = null;
|
|
451
|
+
function initAudioBridge() {
|
|
452
|
+
if (audioContext) return;
|
|
453
|
+
try {
|
|
454
|
+
audioContext = new AudioContext({ sampleRate: 44100 });
|
|
455
|
+
const bufSize = 4096;
|
|
456
|
+
audioProcessor = audioContext.createScriptProcessor(bufSize, 0, 2);
|
|
457
|
+
audioProcessor.onaudioprocess = (e) => {
|
|
458
|
+
const left = e.outputBuffer.getChannelData(0);
|
|
459
|
+
const right = e.outputBuffer.getChannelData(1);
|
|
460
|
+
const interleaved = new Float32Array(left.length * 2);
|
|
461
|
+
bloom.bloom_audio_mix(interleaved);
|
|
462
|
+
for (let i = 0; i < left.length; i++) {
|
|
463
|
+
left[i] = interleaved[i * 2];
|
|
464
|
+
right[i] = interleaved[i * 2 + 1];
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
audioProcessor.connect(audioContext.destination);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
console.warn('[bloom] Web Audio init failed:', e);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function closeAudioBridge() {
|
|
473
|
+
if (audioProcessor) { audioProcessor.disconnect(); audioProcessor = null; }
|
|
474
|
+
if (audioContext) { audioContext.close(); audioContext = null; }
|
|
475
|
+
}
|
|
476
|
+
function resumeAudio() {
|
|
477
|
+
if (audioContext && audioContext.state === 'suspended') {
|
|
478
|
+
audioContext.resume().catch(() => {});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- Input event queue ---
|
|
483
|
+
// Browser input events fire in their own tasks — always BETWEEN frames on a
|
|
484
|
+
// single-threaded wasm host — but a down+up pair that both land in one
|
|
485
|
+
// inter-frame gap would cancel out before the engine's edge detector ever
|
|
486
|
+
// sees the key. So events are queued and drained at a fixed point (top of
|
|
487
|
+
// frame), and an -up whose -down arrived in the same batch is deferred one
|
|
488
|
+
// frame so the tap stays visible. (Same model Bloom Jump ships with.)
|
|
489
|
+
const inputQueue = [];
|
|
490
|
+
let deferredEvents = [];
|
|
491
|
+
function flushInput() {
|
|
492
|
+
const seenDown = new Set();
|
|
493
|
+
const defer = [];
|
|
494
|
+
for (const ev of deferredEvents) applyInputEvent(ev);
|
|
495
|
+
deferredEvents = defer;
|
|
496
|
+
for (const ev of inputQueue) {
|
|
497
|
+
if (ev.up) {
|
|
498
|
+
if (seenDown.has(ev.k)) { defer.push(ev); continue; }
|
|
499
|
+
applyInputEvent(ev);
|
|
500
|
+
} else {
|
|
501
|
+
seenDown.add(ev.k);
|
|
502
|
+
applyInputEvent(ev);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
deferredEvents = defer;
|
|
506
|
+
inputQueue.length = 0;
|
|
507
|
+
}
|
|
508
|
+
function applyInputEvent(ev) {
|
|
509
|
+
if (ev.kind === 'key') {
|
|
510
|
+
if (ev.up) bloom.bloom_inject_key_up(ev.code);
|
|
511
|
+
else bloom.bloom_inject_key_down(ev.code);
|
|
512
|
+
} else if (ev.kind === 'mouse') {
|
|
513
|
+
if (ev.up) bloom.bloom_inject_mouse_button_up(ev.code);
|
|
514
|
+
else bloom.bloom_inject_mouse_button_down(ev.code);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** DOM input + HiDPI canvas sizing. */
|
|
519
|
+
function setupDomBridge() {
|
|
520
|
+
const canvas = document.getElementById('bloom-canvas');
|
|
521
|
+
if (!canvas) return;
|
|
522
|
+
|
|
523
|
+
// HiDPI: keep the canvas backing store + renderer surface in sync with the
|
|
524
|
+
// CSS box and devicePixelRatio. Clamp dpr to 3 to bound GPU cost.
|
|
525
|
+
function syncCanvasSize() {
|
|
526
|
+
const rect = canvas.getBoundingClientRect();
|
|
527
|
+
const logW = Math.max(1, Math.round(rect.width));
|
|
528
|
+
const logH = Math.max(1, Math.round(rect.height));
|
|
529
|
+
const dpr = Math.min(3, Math.max(1, window.devicePixelRatio || 1));
|
|
530
|
+
const physW = Math.round(logW * dpr);
|
|
531
|
+
const physH = Math.round(logH * dpr);
|
|
532
|
+
if (canvas.width !== physW) canvas.width = physW;
|
|
533
|
+
if (canvas.height !== physH) canvas.height = physH;
|
|
534
|
+
if (typeof bloom.bloom_resize === 'function') bloom.bloom_resize(physW, physH, logW, logH);
|
|
535
|
+
}
|
|
536
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(syncCanvasSize).observe(canvas);
|
|
537
|
+
function watchDpr() {
|
|
538
|
+
const q = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
|
539
|
+
q.addEventListener('change', () => { syncCanvasSize(); watchDpr(); }, { once: true });
|
|
540
|
+
}
|
|
541
|
+
watchDpr();
|
|
542
|
+
|
|
543
|
+
// Keyboard. Codes are Bloom's Key enum (engine/src/core/keys.ts), which is
|
|
544
|
+
// NOT GLFW: ENTER=265, ESCAPE=27, UP=256, F1=112, LEFT_SHIFT=280. The old
|
|
545
|
+
// map here used GLFW values, so Enter/Escape/arrows/modifiers/F-keys never
|
|
546
|
+
// matched what games test with isKeyPressed(Key.X) — menus went dead while
|
|
547
|
+
// WASD kept working (letters happen to agree). Keep in lockstep with
|
|
548
|
+
// keys.ts; jump's web/bloom_ffi.js carries the same table.
|
|
549
|
+
const keyMap = {
|
|
550
|
+
'KeyA': 65, 'KeyB': 66, 'KeyC': 67, 'KeyD': 68, 'KeyE': 69, 'KeyF': 70,
|
|
551
|
+
'KeyG': 71, 'KeyH': 72, 'KeyI': 73, 'KeyJ': 74, 'KeyK': 75, 'KeyL': 76,
|
|
552
|
+
'KeyM': 77, 'KeyN': 78, 'KeyO': 79, 'KeyP': 80, 'KeyQ': 81, 'KeyR': 82,
|
|
553
|
+
'KeyS': 83, 'KeyT': 84, 'KeyU': 85, 'KeyV': 86, 'KeyW': 87, 'KeyX': 88,
|
|
554
|
+
'KeyY': 89, 'KeyZ': 90,
|
|
555
|
+
'Digit0': 48, 'Digit1': 49, 'Digit2': 50, 'Digit3': 51, 'Digit4': 52,
|
|
556
|
+
'Digit5': 53, 'Digit6': 54, 'Digit7': 55, 'Digit8': 56, 'Digit9': 57,
|
|
557
|
+
'Space': 32, 'Enter': 265, 'Escape': 27, 'Backspace': 8, 'Tab': 9,
|
|
558
|
+
'ArrowUp': 256, 'ArrowDown': 257, 'ArrowLeft': 258, 'ArrowRight': 259,
|
|
559
|
+
'ShiftLeft': 280, 'ShiftRight': 281, 'ControlLeft': 282, 'ControlRight': 283,
|
|
560
|
+
'AltLeft': 284, 'AltRight': 285,
|
|
561
|
+
'F1': 112, 'F2': 113, 'F3': 114, 'F4': 115, 'F5': 116, 'F6': 117,
|
|
562
|
+
'F7': 118, 'F8': 119, 'F9': 120, 'F10': 121, 'F11': 122, 'F12': 123,
|
|
563
|
+
'Comma': 44, 'Period': 46, 'Slash': 47, 'Semicolon': 59, 'Quote': 39,
|
|
564
|
+
'BracketLeft': 91, 'BracketRight': 93, 'Backslash': 92, 'Minus': 45, 'Equal': 61,
|
|
565
|
+
'Backquote': 96, 'Delete': 127, 'Insert': 260, 'Home': 261, 'End': 262,
|
|
566
|
+
'PageUp': 263, 'PageDown': 264,
|
|
567
|
+
};
|
|
568
|
+
document.addEventListener('keydown', (e) => {
|
|
569
|
+
resumeAudio();
|
|
570
|
+
const code = keyMap[e.code];
|
|
571
|
+
if (code !== undefined) {
|
|
572
|
+
inputQueue.push({ kind: 'key', k: 'k' + code, code, up: false });
|
|
573
|
+
if (e.code !== 'F12' && e.code !== 'F5') e.preventDefault();
|
|
574
|
+
}
|
|
575
|
+
if (typeof bloom.bloom_inject_char === 'function'
|
|
576
|
+
&& e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
577
|
+
bloom.bloom_inject_char(e.key.codePointAt(0));
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
document.addEventListener('keyup', (e) => {
|
|
581
|
+
const code = keyMap[e.code];
|
|
582
|
+
if (code !== undefined) {
|
|
583
|
+
inputQueue.push({ kind: 'key', k: 'k' + code, code, up: true });
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Mouse. DOM buttons are 0=left / 1=middle / 2=right; Bloom's MouseButton
|
|
589
|
+
// is LEFT=0 / RIGHT=1 / MIDDLE=2.
|
|
590
|
+
const domButtonToBloom = [0, 2, 1, 3, 4];
|
|
591
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
592
|
+
if (document.pointerLockElement === canvas) {
|
|
593
|
+
bloom.bloom_inject_mouse_delta(e.movementX, e.movementY);
|
|
594
|
+
} else {
|
|
595
|
+
const rect = canvas.getBoundingClientRect();
|
|
596
|
+
bloom.bloom_inject_mouse_move(e.clientX - rect.left, e.clientY - rect.top);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
600
|
+
resumeAudio();
|
|
601
|
+
// Re-acquire pointer lock on click when the game wants it (the browser
|
|
602
|
+
// released it on ESC, or never granted it because the request happened
|
|
603
|
+
// outside a user gesture).
|
|
604
|
+
if (wantPointerLock && document.pointerLockElement !== canvas) {
|
|
605
|
+
canvas.requestPointerLock?.();
|
|
606
|
+
}
|
|
607
|
+
const b = domButtonToBloom[e.button];
|
|
608
|
+
if (b !== undefined) inputQueue.push({ kind: 'mouse', k: 'm' + b, code: b, up: false });
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
});
|
|
611
|
+
document.addEventListener('mouseup', (e) => {
|
|
612
|
+
const b = domButtonToBloom[e.button];
|
|
613
|
+
if (b !== undefined) inputQueue.push({ kind: 'mouse', k: 'm' + b, code: b, up: true });
|
|
614
|
+
});
|
|
615
|
+
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
616
|
+
canvas.addEventListener('wheel', (e) => {
|
|
617
|
+
// deltaMode 0 = pixels (~100/notch); 1 = lines (~3/notch). Normalise to
|
|
618
|
+
// roughly ±1 per notch, positive = wheel up (native convention).
|
|
619
|
+
const notches = e.deltaMode === 1 ? -e.deltaY / 3 : -e.deltaY / 100;
|
|
620
|
+
bloom.bloom_inject_mouse_wheel(notches);
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
}, { passive: false });
|
|
623
|
+
|
|
624
|
+
// Pointer lock: the browser exits on ESC without delivering the keydown.
|
|
625
|
+
// If the game still wanted the lock, surface that exit as an ESC press so
|
|
626
|
+
// its pause-menu binding fires.
|
|
627
|
+
document.addEventListener('pointerlockchange', () => {
|
|
628
|
+
if (document.pointerLockElement !== canvas && wantPointerLock) {
|
|
629
|
+
inputQueue.push({ kind: 'key', k: 'k27', code: 27, up: false }); // Key.ESCAPE
|
|
630
|
+
inputQueue.push({ kind: 'key', k: 'k27', code: 27, up: true });
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// Touch. Browser touch identifiers are arbitrary; map them onto stable
|
|
635
|
+
// slots 0..N-1 the way the engine's sparse touch API expects. Release goes
|
|
636
|
+
// through the deferred path so a same-frame tap stays visible.
|
|
637
|
+
const touchSlots = new Map(); // identifier → slot
|
|
638
|
+
function slotFor(id) {
|
|
639
|
+
let s = touchSlots.get(id);
|
|
640
|
+
if (s !== undefined) return s;
|
|
641
|
+
const used = new Set(touchSlots.values());
|
|
642
|
+
for (s = 0; used.has(s); s++);
|
|
643
|
+
touchSlots.set(id, s);
|
|
644
|
+
return s;
|
|
645
|
+
}
|
|
646
|
+
function touchXY(t) {
|
|
647
|
+
const rect = canvas.getBoundingClientRect();
|
|
648
|
+
return [t.clientX - rect.left, t.clientY - rect.top];
|
|
649
|
+
}
|
|
650
|
+
canvas.addEventListener('touchstart', (e) => {
|
|
651
|
+
resumeAudio();
|
|
652
|
+
for (const t of e.changedTouches) {
|
|
653
|
+
const [x, y] = touchXY(t);
|
|
654
|
+
bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
|
|
655
|
+
}
|
|
656
|
+
e.preventDefault();
|
|
657
|
+
}, { passive: false });
|
|
658
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
659
|
+
for (const t of e.changedTouches) {
|
|
660
|
+
const [x, y] = touchXY(t);
|
|
661
|
+
bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
|
|
662
|
+
}
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
}, { passive: false });
|
|
665
|
+
const touchEnd = (e) => {
|
|
666
|
+
for (const t of e.changedTouches) {
|
|
667
|
+
const s = touchSlots.get(t.identifier);
|
|
668
|
+
if (s === undefined) continue;
|
|
669
|
+
const [x, y] = touchXY(t);
|
|
670
|
+
bloom.bloom_inject_touch_release(s, x, y);
|
|
671
|
+
touchSlots.delete(t.identifier);
|
|
672
|
+
}
|
|
673
|
+
e.preventDefault();
|
|
674
|
+
};
|
|
675
|
+
canvas.addEventListener('touchend', touchEnd, { passive: false });
|
|
676
|
+
canvas.addEventListener('touchcancel', touchEnd, { passive: false });
|
|
677
|
+
|
|
678
|
+
document.addEventListener('pointerdown', resumeAudio);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --- Auto-boot on import ---
|
|
682
|
+
// The page creates `window.__bloomReady` (a pending promise) in a classic
|
|
683
|
+
// inline <script> during parse; the gated `bootPerryWasm(...)` call awaits it.
|
|
684
|
+
// We resolve it once the engine + FFI are live so the game WASM instantiates
|
|
685
|
+
// against a ready host. If no resolver was installed (engine-only standalone
|
|
686
|
+
// use), this is harmless.
|
|
687
|
+
bootBloomGame().then(
|
|
688
|
+
() => { globalThis.__bloomReadyResolve?.(); },
|
|
689
|
+
(err) => {
|
|
690
|
+
console.error('[bloom] bootstrap failed:', err);
|
|
691
|
+
const loading = document.getElementById('loading');
|
|
692
|
+
if (loading) loading.textContent = 'Failed to start: ' + (err?.message ?? err);
|
|
693
|
+
globalThis.__bloomReadyReject?.(err);
|
|
694
|
+
},
|
|
695
|
+
);
|