@bornengine/engine 0.4.16 → 0.5.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.
Files changed (62) hide show
  1. package/README.md +24 -14
  2. package/native/android/Cargo.lock +577 -28
  3. package/native/android/Cargo.toml +2 -0
  4. package/native/android/src/lib.rs +39 -3
  5. package/native/ios/Cargo.lock +573 -24
  6. package/native/ios/Cargo.toml +2 -0
  7. package/native/ios/src/lib.rs +72 -9
  8. package/native/ios/src/text_input.rs +48 -0
  9. package/native/linux/Cargo.lock +623 -35
  10. package/native/linux/Cargo.toml +3 -0
  11. package/native/macos/Cargo.lock +630 -35
  12. package/native/macos/Cargo.toml +3 -0
  13. package/native/shared/Cargo.lock +430 -27
  14. package/native/shared/Cargo.toml +5 -0
  15. package/native/shared/build.rs +41 -1
  16. package/native/shared/src/engine.rs +54 -2
  17. package/native/shared/src/ffi.rs +54 -1
  18. package/native/shared/src/ffi_core/mod.rs +2 -0
  19. package/native/shared/src/ffi_core/ui.rs +182 -0
  20. package/native/shared/src/input.rs +317 -12
  21. package/native/shared/src/lib.rs +1 -0
  22. package/native/shared/src/renderer/mod.rs +47 -201
  23. package/native/shared/src/renderer/texture_store.rs +5 -0
  24. package/native/shared/src/renderer/ui_pass.rs +742 -0
  25. package/native/shared/src/textures.rs +2 -0
  26. package/native/shared/src/ui/commands.rs +213 -0
  27. package/native/shared/src/ui/egui.rs +1282 -0
  28. package/native/shared/src/ui/imgui.rs +1056 -0
  29. package/native/shared/src/ui/input.rs +173 -0
  30. package/native/shared/src/ui/mod.rs +252 -0
  31. package/native/shared/src/ui/responses.rs +61 -0
  32. package/native/tvos/Cargo.lock +576 -27
  33. package/native/tvos/Cargo.toml +2 -0
  34. package/native/visionos/Cargo.lock +571 -22
  35. package/native/visionos/Cargo.toml +2 -0
  36. package/native/watchos/src/ffi_stubs.rs +27 -0
  37. package/native/web/Cargo.lock +346 -11
  38. package/native/web/Cargo.toml +6 -0
  39. package/native/web/bloom_glue.js +84 -11
  40. package/native/web/build.sh +150 -34
  41. package/native/web/src/lib.rs +2 -0
  42. package/native/web/src/ui_ffi.rs +148 -0
  43. package/native/windows/Cargo.lock +638 -38
  44. package/native/windows/Cargo.toml +3 -0
  45. package/native/windows/src/lib.rs +50 -1
  46. package/native/windows/src/windows_text.rs +69 -0
  47. package/package.json +91 -2
  48. package/src/debug-ui/index.ts +4 -0
  49. package/src/debug-ui/types.ts +1 -0
  50. package/src/game/adapters/audio-source-component.ts +67 -0
  51. package/src/game/adapters/rigid-body-component.ts +69 -0
  52. package/src/game/adapters/scene-node-component.ts +49 -0
  53. package/src/game/game-component.ts +89 -0
  54. package/src/game/game-object.ts +338 -0
  55. package/src/game/game-scene.ts +303 -0
  56. package/src/game/index.ts +7 -0
  57. package/src/game/transform.ts +315 -0
  58. package/src/index.ts +27 -0
  59. package/src/ui/api.ts +240 -0
  60. package/src/ui/index.ts +5 -0
  61. package/src/ui/opcodes.ts +66 -0
  62. package/src/ui/types.ts +82 -0
@@ -52,6 +52,8 @@ let manifestPaths = null; // Set<string> when a manifest was loaded
52
52
  // browser only grants pointer lock inside a user gesture, and revokes it on
53
53
  // ESC without delivering the keydown. Both mismatches are reconciled here.
54
54
  let wantPointerLock = false;
55
+ let uiTextInput = null;
56
+ let uiTextInputFocused = false;
55
57
 
56
58
  /**
57
59
  * Idempotent engine bootstrap. Safe to call multiple times; only the first
@@ -204,6 +206,14 @@ function buildFfiImports() {
204
206
  imports.bloom_measure_text_ex = (font, text, size, spacing) =>
205
207
  bloom.bloom_measure_text_ex_str(font, String(text), size, spacing);
206
208
 
209
+ // Route whole-string UI commands through wasm-bindgen string parameters.
210
+ imports.bloom_ui_command = (backend, opcode, id, a, b, c, d, text) =>
211
+ bloom.bloom_ui_command_str(backend, opcode, id, a, b, c, d, String(text));
212
+ imports.bloom_ui_scratch_command = (backend, opcode, id, count, text) =>
213
+ bloom.bloom_ui_scratch_command_str(backend, opcode, id, count, String(text));
214
+ imports.bloom_ui_inject_text = (text) =>
215
+ bloom.bloom_ui_inject_text_str(String(text));
216
+
207
217
  // --- Materials & post-FX: shader source strings route to _str variants ---
208
218
  const materialVariants = [
209
219
  'bloom_compile_material', 'bloom_compile_material_refractive',
@@ -384,6 +394,7 @@ function startRafLoop() {
384
394
  document.getElementById('loading')?.remove();
385
395
  }
386
396
  flushInput();
397
+ syncUiKeyboardRequest();
387
398
  bloom.bloom_begin_drawing();
388
399
  if (gameCallback !== null && typeof globalThis.callWasmClosure === 'function') {
389
400
  const dt = bloom.bloom_get_delta_time();
@@ -520,6 +531,44 @@ function setupDomBridge() {
520
531
  const canvas = document.getElementById('bloom-canvas');
521
532
  if (!canvas) return;
522
533
 
534
+ // Browsers deliver text and IME composition through DOM editing events,
535
+ // not keyboard key codes. Keep a real focusable control so mobile browsers
536
+ // can show their soft keyboard, while visually leaving the game canvas
537
+ // unchanged.
538
+ uiTextInput = document.createElement('textarea');
539
+ uiTextInput.setAttribute('aria-label', 'Game text input');
540
+ uiTextInput.autocomplete = 'off';
541
+ uiTextInput.autocapitalize = 'off';
542
+ uiTextInput.spellcheck = false;
543
+ Object.assign(uiTextInput.style, {
544
+ position: 'fixed', left: '0', bottom: '0', width: '1px', height: '1px',
545
+ padding: '0', border: '0', opacity: '0', resize: 'none', overflow: 'hidden',
546
+ zIndex: '-1', caretColor: 'transparent',
547
+ });
548
+ document.body.appendChild(uiTextInput);
549
+ let composing = false;
550
+ uiTextInput.addEventListener('compositionstart', () => { composing = true; });
551
+ uiTextInput.addEventListener('compositionend', (e) => {
552
+ composing = false;
553
+ if (e.data) bloom.bloom_ui_inject_text(String(e.data));
554
+ uiTextInput.value = '';
555
+ });
556
+ uiTextInput.addEventListener('input', (e) => {
557
+ if (composing || e.isComposing || e.inputType === 'insertFromComposition') {
558
+ uiTextInput.value = '';
559
+ return;
560
+ }
561
+ if (e.inputType?.startsWith('insert') && e.data) {
562
+ bloom.bloom_ui_inject_text(String(e.data));
563
+ }
564
+ uiTextInput.value = '';
565
+ });
566
+ uiTextInput.addEventListener('paste', (e) => {
567
+ const text = e.clipboardData?.getData('text/plain') ?? '';
568
+ if (text) bloom.bloom_ui_inject_text(text);
569
+ e.preventDefault();
570
+ });
571
+
523
572
  // HiDPI: keep the canvas backing store + renderer surface in sync with the
524
573
  // CSS box and devicePixelRatio. Clamp dpr to 3 to bound GPU cost.
525
574
  function syncCanvasSize() {
@@ -570,10 +619,10 @@ function setupDomBridge() {
570
619
  const code = keyMap[e.code];
571
620
  if (code !== undefined) {
572
621
  inputQueue.push({ kind: 'key', k: 'k' + code, code, up: false });
573
- if (e.code !== 'F12' && e.code !== 'F5') e.preventDefault();
622
+ if (!uiTextInputFocused && e.code !== 'F12' && e.code !== 'F5') e.preventDefault();
574
623
  }
575
624
  if (typeof bloom.bloom_inject_char === 'function'
576
- && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
625
+ && !uiTextInputFocused && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
577
626
  bloom.bloom_inject_char(e.key.codePointAt(0));
578
627
  }
579
628
  });
@@ -634,14 +683,21 @@ function setupDomBridge() {
634
683
  // Touch. Browser touch identifiers are arbitrary; map them onto stable
635
684
  // slots 0..N-1 the way the engine's sparse touch API expects. Release goes
636
685
  // 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;
686
+ const touchSlots = new Map(); // browser identifier → stable engine slot
687
+ const touchCapacity = Math.max(0, Math.min(10,
688
+ (typeof bloom.bloom_get_max_touch_points === 'function'
689
+ ? bloom.bloom_get_max_touch_points() : 10) | 0));
690
+ function slotForStart(id) {
691
+ const existing = touchSlots.get(id);
692
+ if (existing !== undefined) return existing;
641
693
  const used = new Set(touchSlots.values());
642
- for (s = 0; used.has(s); s++);
643
- touchSlots.set(id, s);
644
- return s;
694
+ for (let slot = 0; slot < touchCapacity; slot++) {
695
+ if (!used.has(slot)) {
696
+ touchSlots.set(id, slot);
697
+ return slot;
698
+ }
699
+ }
700
+ return undefined;
645
701
  }
646
702
  function touchXY(t) {
647
703
  const rect = canvas.getBoundingClientRect();
@@ -650,15 +706,19 @@ function setupDomBridge() {
650
706
  canvas.addEventListener('touchstart', (e) => {
651
707
  resumeAudio();
652
708
  for (const t of e.changedTouches) {
709
+ const slot = slotForStart(t.identifier);
710
+ if (slot === undefined) continue;
653
711
  const [x, y] = touchXY(t);
654
- bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
712
+ bloom.bloom_inject_touch(slot, x, y, 1);
655
713
  }
656
714
  e.preventDefault();
657
715
  }, { passive: false });
658
716
  canvas.addEventListener('touchmove', (e) => {
659
717
  for (const t of e.changedTouches) {
718
+ const slot = touchSlots.get(t.identifier);
719
+ if (slot === undefined) continue;
660
720
  const [x, y] = touchXY(t);
661
- bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
721
+ bloom.bloom_inject_touch(slot, x, y, 1);
662
722
  }
663
723
  e.preventDefault();
664
724
  }, { passive: false });
@@ -678,6 +738,19 @@ function setupDomBridge() {
678
738
  document.addEventListener('pointerdown', resumeAudio);
679
739
  }
680
740
 
741
+ function syncUiKeyboardRequest() {
742
+ if (!uiTextInput || typeof bloom.bloom_ui_take_keyboard_request !== 'function') return;
743
+ const request = bloom.bloom_ui_take_keyboard_request(0);
744
+ if (request > 0.5 && !uiTextInputFocused) {
745
+ uiTextInputFocused = true;
746
+ uiTextInput.focus({ preventScroll: true });
747
+ } else if (request >= 0 && request <= 0.5 && uiTextInputFocused) {
748
+ uiTextInputFocused = false;
749
+ uiTextInput.blur();
750
+ uiTextInput.value = '';
751
+ }
752
+ }
753
+
681
754
  // --- Auto-boot on import ---
682
755
  // The page creates `window.__bloomReady` (a pending promise) in a classic
683
756
  // inline <script> during parse; the gated `bootPerryWasm(...)` call awaits it.
@@ -2,7 +2,7 @@
2
2
  # Build Bloom Engine for Web
3
3
  #
4
4
  # Usage:
5
- # ./native/web/build.sh [game.ts] [--output dist/]
5
+ # ./native/web/build.sh [--dev | --release] [game.ts] [--output dist/]
6
6
  #
7
7
  # Steps:
8
8
  # 1. Build bloom_web.wasm via wasm-pack
@@ -12,54 +12,167 @@
12
12
  # Prerequisites:
13
13
  # - wasm-pack: cargo install wasm-pack
14
14
  # - perry: ../../../perry/perry/target/release/perry (or in PATH)
15
- # - wasm-opt (optional): for binary size optimization
16
15
 
17
- set -e
16
+ set -euo pipefail
18
17
 
19
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
- ENGINE_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
19
+ ENGINE_DIR="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
21
20
  WEB_CRATE="$SCRIPT_DIR"
22
- OUTPUT_DIR="${2:-$ENGINE_DIR/dist/web}"
23
- GAME_FILE="$1"
21
+ BUILD_PROFILE="--release"
22
+ PROFILE_SET=0
23
+ OUTPUT_DIR=""
24
+ GAME_FILE=""
25
+ GAME_FILE_INPUT=""
26
+ GAME_DIR=""
27
+
28
+ usage() {
29
+ cat <<'USAGE'
30
+ Usage: ./native/web/build.sh [--dev | --release] [game.ts] [--output dist/]
31
+
32
+ Profiles:
33
+ --dev Faster iterative build; skips wasm-opt.
34
+ --release Optimized build (default); runs wasm-opt once with -Oz.
35
+
36
+ Options:
37
+ --output DIR Write the assembled site to DIR (default: dist/web).
38
+ -h, --help Show this help.
39
+ USAGE
40
+ }
41
+
42
+ usage_error() {
43
+ echo "ERROR: $1" >&2
44
+ usage >&2
45
+ exit 2
46
+ }
47
+
48
+ while (($#)); do
49
+ case "$1" in
50
+ --dev|--release)
51
+ if ((PROFILE_SET)) && [[ "$BUILD_PROFILE" != "$1" ]]; then
52
+ usage_error "--dev and --release cannot be used together"
53
+ fi
54
+ BUILD_PROFILE="$1"
55
+ PROFILE_SET=1
56
+ shift
57
+ ;;
58
+ --output)
59
+ (($# >= 2)) || usage_error "--output requires a directory"
60
+ [[ -n "$2" ]] || usage_error "--output requires a directory"
61
+ OUTPUT_DIR="$2"
62
+ shift 2
63
+ ;;
64
+ --output=*)
65
+ OUTPUT_DIR="${1#*=}"
66
+ [[ -n "$OUTPUT_DIR" ]] || usage_error "--output requires a directory"
67
+ shift
68
+ ;;
69
+ -h|--help)
70
+ usage
71
+ exit 0
72
+ ;;
73
+ -* )
74
+ usage_error "unknown option: $1"
75
+ ;;
76
+ *)
77
+ [[ -z "$GAME_FILE" ]] || usage_error "only one game file may be provided"
78
+ GAME_FILE="$1"
79
+ shift
80
+ ;;
81
+ esac
82
+ done
83
+
84
+ OUTPUT_DIR="${OUTPUT_DIR:-$ENGINE_DIR/dist/web}"
85
+ if [[ "$OUTPUT_DIR" != /* ]]; then
86
+ OUTPUT_DIR="$PWD/$OUTPUT_DIR"
87
+ fi
88
+ OUTPUT_DIR="$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$OUTPUT_DIR")"
24
89
 
25
- # Resolve the game file to an absolute path NOW, while still in the caller's
26
- # working directory — the build cd's into the web crate before compiling, so a
27
- # relative path like `examples/pong/main.ts` would otherwise no longer resolve.
90
+ # Resolve game paths before validating output: cleanup must never remove game
91
+ # source files or the assets directory that will be copied into the build.
28
92
  if [ -n "$GAME_FILE" ]; then
29
93
  if [ -f "$GAME_FILE" ]; then
30
- GAME_FILE="$(cd "$(dirname "$GAME_FILE")" && pwd)/$(basename "$GAME_FILE")"
94
+ GAME_FILE_INPUT="$(python3 -c 'import os, sys; path = sys.argv[1]; print(os.path.join(os.path.realpath(os.path.dirname(path) or "."), os.path.basename(path)))' "$GAME_FILE")"
95
+ GAME_FILE="$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$GAME_FILE_INPUT")"
96
+ GAME_DIR="$(dirname "$GAME_FILE")"
31
97
  else
32
98
  echo "ERROR: game file not found: $GAME_FILE"
33
99
  exit 1
34
100
  fi
35
101
  fi
36
102
 
103
+ if [[ "$OUTPUT_DIR" == "/" || "$OUTPUT_DIR" == "$WEB_CRATE" || "$OUTPUT_DIR" == "$WEB_CRATE/"* || "$WEB_CRATE" == "$OUTPUT_DIR/"* ]]; then
104
+ usage_error "--output must not overlap the native/web source tree"
105
+ fi
106
+
107
+ # Resolve game-output cleanup paths against the source file and its assets
108
+ # directory. This permits common output folders such as <game>/dist/web while
109
+ # rejecting destinations whose owned artifacts would erase game inputs.
110
+ if [ -n "$GAME_FILE" ]; then
111
+ if ! python3 - "$OUTPUT_DIR" "$GAME_FILE_INPUT" "$GAME_FILE" "$GAME_DIR" <<'PY'
112
+ import os
113
+ import sys
114
+
115
+ output_dir, game_file_input, game_file_target, game_dir = sys.argv[1:]
116
+ output_dir = os.path.realpath(output_dir)
117
+ game_file_input = os.path.abspath(game_file_input)
118
+ game_file_target = os.path.realpath(game_file_target)
119
+ game_assets_target = os.path.join(os.path.realpath(game_dir), "assets")
120
+ game_assets_input = os.path.join(os.path.dirname(game_file_input), "assets")
121
+ cleaned_paths = [
122
+ os.path.join(output_dir, "pkg"),
123
+ os.path.join(output_dir, "assets"),
124
+ os.path.join(output_dir, "index.html"),
125
+ os.path.join(output_dir, "bloom_glue.js"),
126
+ os.path.join(output_dir, "jolt_bridge.js"),
127
+ ]
128
+ source_paths = [game_file_input, game_file_target]
129
+ for game_assets in (game_assets_input, game_assets_target):
130
+ if os.path.lexists(game_assets):
131
+ source_paths.extend((os.path.abspath(game_assets), os.path.realpath(game_assets)))
132
+
133
+ cleanup_aliases = []
134
+ for cleaned in cleaned_paths:
135
+ cleaned = os.path.abspath(cleaned)
136
+ cleanup_aliases.extend((cleaned, os.path.realpath(cleaned)))
137
+
138
+ def overlaps(left, right):
139
+ try:
140
+ common = os.path.commonpath((left, right))
141
+ if common == left or common == right:
142
+ return True
143
+ return os.path.lexists(left) and os.path.lexists(right) and os.path.samefile(left, right)
144
+ except (OSError, ValueError):
145
+ return False
146
+
147
+ sys.exit(any(overlaps(cleaned, source) for cleaned in cleanup_aliases for source in source_paths))
148
+ PY
149
+ then
150
+ usage_error "--output overlaps with game source inputs"
151
+ fi
152
+ fi
153
+
154
+ PERRY_TMP=""
155
+ cleanup() {
156
+ if [[ -n "$PERRY_TMP" ]]; then
157
+ rm -rf "$PERRY_TMP"
158
+ fi
159
+ }
160
+ trap cleanup EXIT
161
+
37
162
  echo "=== Bloom Web Build ==="
163
+ echo " Profile: $BUILD_PROFILE"
38
164
  echo ""
39
165
 
40
166
  # 1. Build Bloom WASM via wasm-pack
41
- echo "[1/4] Building bloom_web.wasm..."
167
+ echo "[1/3] Building bloom_web.wasm..."
42
168
  cd "$WEB_CRATE"
43
- wasm-pack build --target web --out-dir pkg --no-typescript 2>&1 | tail -3
169
+ wasm-pack build --target web --out-dir pkg --no-typescript "$BUILD_PROFILE" 2>&1 | tail -3
44
170
  echo " Output: $WEB_CRATE/pkg/"
45
171
 
46
- # 2. Optimize WASM binary (if wasm-opt is available)
47
- if command -v wasm-opt &> /dev/null; then
48
- echo "[2/4] Optimizing WASM with wasm-opt..."
49
- WASM_FILE="$WEB_CRATE/pkg/bloom_web_bg.wasm"
50
- ORIG_SIZE=$(wc -c < "$WASM_FILE")
51
- wasm-opt -Oz "$WASM_FILE" -o "$WASM_FILE.opt"
52
- mv "$WASM_FILE.opt" "$WASM_FILE"
53
- OPT_SIZE=$(wc -c < "$WASM_FILE")
54
- echo " Optimized: $((ORIG_SIZE / 1024))KB → $((OPT_SIZE / 1024))KB"
55
- else
56
- echo "[2/4] Skipping wasm-opt (not installed). Install with: cargo install wasm-opt"
57
- fi
58
-
59
- # 3. Compile game (if provided)
172
+ # 2. Compile game (if provided)
60
173
  PERRY_HTML=""
61
174
  if [ -n "$GAME_FILE" ] && [ -f "$GAME_FILE" ]; then
62
- echo "[3/4] Compiling game: $GAME_FILE"
175
+ echo "[2/3] Compiling game: $GAME_FILE"
63
176
 
64
177
  # Find perry compiler
65
178
  PERRY=""
@@ -78,16 +191,21 @@ if [ -n "$GAME_FILE" ] && [ -f "$GAME_FILE" ]; then
78
191
  # Use a temp dir (portable across GNU/BSD mktemp) so cleanup is a single rm.
79
192
  PERRY_TMP="$(mktemp -d)"
80
193
  PERRY_HTML="$PERRY_TMP/game.html"
81
- $PERRY "$GAME_FILE" --target wasm -o "$PERRY_HTML"
194
+ "$PERRY" "$GAME_FILE" --target wasm -o "$PERRY_HTML"
82
195
  echo " Game compiled to WASM ($PERRY_HTML)"
83
196
  else
84
- echo "[3/4] No game file specified, skipping game compilation"
197
+ echo "[2/3] No game file specified, skipping game compilation"
85
198
  fi
86
199
 
87
- # 4. Assemble output directory
88
- echo "[4/4] Assembling output..."
200
+ # 3. Assemble output directory
201
+ echo "[3/3] Assembling output..."
89
202
  mkdir -p "$OUTPUT_DIR"
90
203
 
204
+ # Replace artifacts owned by this build so repeated builds never nest pkg or
205
+ # assets directories, or leave stale files from the previous game/profile.
206
+ rm -rf "$OUTPUT_DIR/pkg" "$OUTPUT_DIR/assets"
207
+ rm -f "$OUTPUT_DIR/index.html" "$OUTPUT_DIR/bloom_glue.js" "$OUTPUT_DIR/jolt_bridge.js"
208
+
91
209
  # Copy Bloom WASM package
92
210
  cp -r "$WEB_CRATE/pkg" "$OUTPUT_DIR/pkg"
93
211
 
@@ -99,7 +217,6 @@ if [ -n "$PERRY_HTML" ]; then
99
217
  # Game build: splice the Bloom bootstrap into Perry's HTML and gate the game's
100
218
  # bootPerryWasm() call on engine readiness → dist/web/index.html.
101
219
  python3 "$WEB_CRATE/splice_game.py" "$PERRY_HTML" "$OUTPUT_DIR/index.html"
102
- rm -rf "$PERRY_TMP"
103
220
  echo " Spliced game + engine into index.html"
104
221
  else
105
222
  # No game: ship the engine-only standalone page.
@@ -109,7 +226,6 @@ fi
109
226
 
110
227
  # Copy game assets (if game directory has an assets/ folder)
111
228
  if [ -n "$GAME_FILE" ]; then
112
- GAME_DIR="$(dirname "$(realpath "$GAME_FILE")")"
113
229
  if [ -d "$GAME_DIR/assets" ]; then
114
230
  cp -r "$GAME_DIR/assets" "$OUTPUT_DIR/assets"
115
231
  echo " Copied assets/ directory"
@@ -313,7 +313,9 @@ pub fn bloom_get_time() -> f64 {
313
313
  // in input_ffi.rs (2000-line file policy).
314
314
  mod input_ffi;
315
315
  mod material_ffi;
316
+ mod ui_ffi;
316
317
  pub use input_ffi::*;
318
+ pub use ui_ffi::*;
317
319
 
318
320
  // EN-063 — web FFI parity for full-3D games: mesh/instance/texture-array
319
321
  // scratch builders, staged-model bytes, env-HDR bytes, profiler text,
@@ -0,0 +1,148 @@
1
+ use super::engine;
2
+ use bloom_shared::ui::{UiBackend, UiCommand, UiOpcode};
3
+ use wasm_bindgen::prelude::*;
4
+
5
+ #[wasm_bindgen]
6
+ pub fn bloom_ui_command_str(
7
+ backend: f64,
8
+ opcode: f64,
9
+ id: f64,
10
+ a: f64,
11
+ b: f64,
12
+ c: f64,
13
+ d: f64,
14
+ text: &str,
15
+ ) -> f64 {
16
+ let Some(backend) = UiBackend::from_abi(backend) else {
17
+ return 0.0;
18
+ };
19
+ let Some(opcode) = UiOpcode::from_abi(opcode) else {
20
+ return 0.0;
21
+ };
22
+ let Some(id) = bloom_shared::ui::id_from_abi(id) else {
23
+ return 0.0;
24
+ };
25
+ if engine()
26
+ .ui
27
+ .queue_command(UiCommand::new(backend, opcode, id, [a, b, c, d], text))
28
+ {
29
+ 1.0
30
+ } else {
31
+ 0.0
32
+ }
33
+ }
34
+
35
+ #[wasm_bindgen]
36
+ pub fn bloom_ui_scratch_reset(backend: f64) {
37
+ if let Some(backend) = UiBackend::from_abi(backend) {
38
+ engine().ui.reset_scratch(backend);
39
+ }
40
+ }
41
+
42
+ #[wasm_bindgen]
43
+ pub fn bloom_ui_scratch_push_f64(backend: f64, value: f64) {
44
+ if let Some(backend) = UiBackend::from_abi(backend) {
45
+ engine().ui.push_scratch(backend, value);
46
+ }
47
+ }
48
+
49
+ #[wasm_bindgen]
50
+ pub fn bloom_ui_scratch_command_str(
51
+ backend: f64,
52
+ opcode: f64,
53
+ id: f64,
54
+ count: f64,
55
+ text: &str,
56
+ ) -> f64 {
57
+ let Some(backend) = UiBackend::from_abi(backend) else {
58
+ return 0.0;
59
+ };
60
+ let Some(opcode) = UiOpcode::from_abi(opcode) else {
61
+ return 0.0;
62
+ };
63
+ let Some(id) = bloom_shared::ui::id_from_abi(id) else {
64
+ return 0.0;
65
+ };
66
+ let Some(count) = bloom_shared::ui::id_from_abi(count) else {
67
+ return 0.0;
68
+ };
69
+ if engine()
70
+ .ui
71
+ .queue_scratch_command(backend, opcode, id, count as usize, text)
72
+ {
73
+ 1.0
74
+ } else {
75
+ 0.0
76
+ }
77
+ }
78
+
79
+ #[wasm_bindgen]
80
+ pub fn bloom_ui_inject_text_str(text: &str) {
81
+ engine().ui.inject_text(text.to_owned());
82
+ }
83
+
84
+ #[wasm_bindgen]
85
+ pub fn bloom_ui_response(backend: f64, id: f64, field: f64) -> f64 {
86
+ let Some(backend) = UiBackend::from_abi(backend) else {
87
+ return 0.0;
88
+ };
89
+ let Some(id) = bloom_shared::ui::id_from_abi(id) else {
90
+ return 0.0;
91
+ };
92
+ let Some(field) = bloom_shared::ui::id_from_abi(field) else {
93
+ return 0.0;
94
+ };
95
+ let response = engine().ui.response(backend, id);
96
+ match field {
97
+ 0 => response.clicked as u8 as f64,
98
+ 1 => response.changed as u8 as f64,
99
+ 2 => response.hovered as u8 as f64,
100
+ 3 => response.focused as u8 as f64,
101
+ 4 => response.dragged as u8 as f64,
102
+ 5 => response.value,
103
+ 6 => engine().ui.has_response(backend, id) as u8 as f64,
104
+ _ => 0.0,
105
+ }
106
+ }
107
+
108
+ #[wasm_bindgen]
109
+ pub fn bloom_ui_response_text(backend: f64, id: f64) -> String {
110
+ let Some(backend) = UiBackend::from_abi(backend) else {
111
+ return String::new();
112
+ };
113
+ let Some(id) = bloom_shared::ui::id_from_abi(id) else {
114
+ return String::new();
115
+ };
116
+ engine().ui.response_text(backend, id)
117
+ }
118
+
119
+ #[wasm_bindgen]
120
+ pub fn bloom_ui_is_available(backend: f64) -> f64 {
121
+ let Some(backend) = UiBackend::from_abi(backend) else {
122
+ return 0.0;
123
+ };
124
+ engine().ui.is_available(backend) as u8 as f64
125
+ }
126
+
127
+ #[wasm_bindgen]
128
+ pub fn bloom_ui_wants_input(backend: f64, kind: f64) -> f64 {
129
+ let Some(backend) = UiBackend::from_abi(backend) else {
130
+ return 0.0;
131
+ };
132
+ let Some(kind) = bloom_shared::ui::id_from_abi(kind) else {
133
+ return 0.0;
134
+ };
135
+ engine().ui.wants_input(backend, kind) as u8 as f64
136
+ }
137
+
138
+ #[wasm_bindgen]
139
+ pub fn bloom_ui_take_keyboard_request(backend: f64) -> f64 {
140
+ let Some(UiBackend::Egui) = UiBackend::from_abi(backend) else {
141
+ return -1.0;
142
+ };
143
+ match engine().ui.take_keyboard_request() {
144
+ Some(true) => 1.0,
145
+ Some(false) => 0.0,
146
+ None => -1.0,
147
+ }
148
+ }