-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathruntime.js
More file actions
2746 lines (2638 loc) · 126 KB
/
Copy pathruntime.js
File metadata and controls
2746 lines (2638 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// wasm-web-kit runtime. Hand-rolled, no Emscripten: loads a wasm32-wasi module
// built with the WASI SDK, provides the WASI preview1 syscalls the binary
// references, and implements the include/abi.h `env` contract on Canvas2D +
// WebAudio + DOM input. The module is a reactor exporting _initialize/boot/frame.
//
// The host page sets window.WASMWEB = { logicalWidth, logicalHeight, wasmUrl,
// assetRoot, canvasId, title } before loading this script (see shell.html), so
// the same runtime drives any C/C++ game.
'use strict';
// ============================================================================
// sf::Keyboard::Key -> DOM mapping
// ----------------------------------------------------------------------------
// SFML 2.6 sf::Keyboard::Key enum numeric values (fixed by the SFML ABI). The
// C++ Window/Event.hpp web shim passes these same integers to key_pressed() and
// stamps them into KeyPressed/KeyReleased events. Keep this table in lockstep
// with that header. We map only the keys BOSS-MAN actually uses; everything
// else returns "not pressed".
//
// Letters: A=0 B=1 C=2 D=3 E=4 F=5 ... P=15 R=17 S=18 V=21 W=22 Z=25
// Num row: Num0=26 Num1=27 ... Num9=35
// Escape=36 Space=57 BackSpace=59
// Left=71 Right=72 Up=73 Down=74
// Numpad0=75 Numpad1=76 ... Numpad8=83
// (Full enum: https://www.sfml-dev.org/documentation/2.6.1/Keyboard_8hpp.html)
// ============================================================================
// Ramer-Douglas-Peucker polyline simplification (used by img_polygon_from_alpha).
// Drops vertices whose perpendicular distance to the chord is below `epsilon`.
function rdpSimplify(points, epsilon) {
if (points.length < 3) return points.slice();
const sqr = (a) => a * a;
const distSq = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
if (dx === 0 && dy === 0) return sqr(p[0] - a[0]) + sqr(p[1] - a[1]);
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx*dx + dy*dy);
const tt = Math.max(0, Math.min(1, t));
return sqr(p[0] - (a[0] + tt * dx)) + sqr(p[1] - (a[1] + tt * dy));
};
const eps2 = epsilon * epsilon;
const keep = new Uint8Array(points.length);
keep[0] = 1; keep[points.length - 1] = 1;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [s, e] = stack.pop();
let maxD = 0, maxI = -1;
for (let i = s + 1; i < e; i++) {
const d = distSq(points[i], points[s], points[e]);
if (d > maxD) { maxD = d; maxI = i; }
}
if (maxD > eps2 && maxI > 0) {
keep[maxI] = 1;
stack.push([s, maxI], [maxI, e]);
}
}
const out = [];
for (let i = 0; i < points.length; i++) if (keep[i]) out.push(points[i]);
return out;
}
const SF_KEY = {
// SFML enum order: A..Z = 0..25, Num0..Num9 = 26..35. Filling the map
// out fully so games can read full alphanumeric input (username entry,
// level editor letter palette, etc.) — partial map used to drop B/G/H/...
// and Return on the floor.
0: 'KeyA', 1: 'KeyB', 2: 'KeyC', 3: 'KeyD', 4: 'KeyE', 5: 'KeyF',
6: 'KeyG', 7: 'KeyH', 8: 'KeyI', 9: 'KeyJ', 10: 'KeyK', 11: 'KeyL',
12: 'KeyM', 13: 'KeyN', 14: 'KeyO', 15: 'KeyP', 16: 'KeyQ', 17: 'KeyR',
18: 'KeyS', 19: 'KeyT', 20: 'KeyU', 21: 'KeyV', 22: 'KeyW', 23: 'KeyX',
24: 'KeyY', 25: 'KeyZ',
26: 'Digit0', 27: 'Digit1', 28: 'Digit2', 29: 'Digit3', 30: 'Digit4',
31: 'Digit5', 32: 'Digit6', 33: 'Digit7', 34: 'Digit8', 35: 'Digit9',
36: 'Escape',
57: 'Space',
58: 'Enter',
59: 'Backspace',
71: 'ArrowLeft', 72: 'ArrowRight', 73: 'ArrowUp', 74: 'ArrowDown',
75: 'Numpad0', 76: 'Numpad1', 77: 'Numpad2', 78: 'Numpad3', 79: 'Numpad4',
80: 'Numpad5', 81: 'Numpad6', 82: 'Numpad7', 83: 'Numpad8',
};
// Reverse map: DOM KeyboardEvent.code -> sf::Keyboard code. Built from SF_KEY so
// keydown/keyup can record presses and stamp events with the right enum int.
const DOM_TO_SF = (() => {
const m = new Map();
for (const k of Object.keys(SF_KEY)) m.set(SF_KEY[k], Number(k));
return m;
})();
// ============================================================================
// sf::Event::EventType -> integer (SFML 2.6 order, fixed by the ABI)
// ----------------------------------------------------------------------------
// Closed=0 Resized=1 LostFocus=2 GainedFocus=3 TextEntered=4
// KeyPressed=5 KeyReleased=6 MouseWheelMoved=7 MouseWheelScrolled=8
// MouseButtonPressed=9 MouseButtonReleased=10 MouseMoved=11 ...
// evt_poll fills {type,a,b,c,d}:
// KeyPressed/KeyReleased: a=sfKeyCode b=shift(0/1) c=system/cmd(0/1) d=0
// MouseButtonPressed/Released: a=button(0=L,1=R) b=x c=y d=0
// MouseMoved: a=x b=y
// Resized: a=width b=height
// Closed: (no payload)
// ============================================================================
const EVT = {
Closed: 0, Resized: 1, LostFocus: 2, GainedFocus: 3, TextEntered: 4,
KeyPressed: 5, KeyReleased: 6, MouseWheelMoved: 7, MouseWheelScrolled: 8,
MouseButtonPressed: 9, MouseButtonReleased: 10, MouseMoved: 11,
// Per-finger touch (SFML 2.6 order) carried ALONGSIDE the finger-0 mouse
// events above: legacy scenes consume the mouse pointer, multi-touch-aware
// scenes (the 3D bonus D-pad) consume these. {a:finger, b:x, c:y}.
TouchBegan: 19, TouchMoved: 20, TouchEnded: 21,
};
// Per-game configuration. The host page sets window.WASMWEB before loading this
// script; anything omitted falls back to these defaults. This is what makes the
// runtime reusable for any C/C++ game (not just BOSS-MAN).
const CFG = Object.assign({
logicalWidth: 1184, // the game's fixed logical render width
logicalHeight: 666, // ...and height (backing store keeps this aspect)
wasmUrl: 'game.wasm', // reactor module exporting _initialize/boot/frame
assetRoot: '../assets', // where preloaded assets + manifest.json live
canvasId: 'game', // <canvas> element id
title: null, // optional document.title
cacheBust: '', // build token; appended as ?v=... to every fetch so a
// rebuilt wasm/asset is never served stale from cache
}, (typeof window !== 'undefined' && window.WASMWEB) || {});
const LOGICAL_W = CFG.logicalWidth;
const LOGICAL_H = CFG.logicalHeight;
// Cache-bust suffix appended to wasm + every asset fetch. Browsers cache
// runtime.js / *.wasm / assets aggressively by filename; without a per-build
// token a rebuild silently serves the OLD files (the recurring "I fixed it but
// nothing changed" trap). build.sh stamps CFG.cacheBust with the build time.
const BUST = CFG.cacheBust ? ('?v=' + encodeURIComponent(CFG.cacheBust)) : '';
const bust = (url) => url + (BUST && !url.includes('?') ? BUST : '');
// Text-default emoji (Emoji=Yes, Emoji_Presentation=No): Canvas2D renders these
// as B&W glyphs unless followed by U+FE0F. The game stores some bare (e.g. ⛰
// U+26F0 renders as a white silhouette). We append FE0F to force colour emoji.
// Curated to game-relevant symbols; excludes ©®™ and keycap bases so HUD/text
// is untouched.
const TEXT_DEFAULT_EMOJI = new Set([
0x26F0, // ⛰ mountain
0x26FA, // ⛺ tent
0x26EA, // ⛪ church
0x26F2, // ⛲ fountain
0x26F5, // ⛵ boat (usually emoji, harmless)
0x2618, // ☘ shamrock
0x2600, 0x2601, // ☀ ☁
0x2602, // ☂ umbrella
0x2614, // ☔
0x26C8, // ⛈
0x2744, // ❄ snowflake
0x2728, // ✨ (emoji default, harmless)
0x2604, // ☄ comet
0x2702, // ✂ scissors
0x2708, // ✈ airplane
0x2709, // ✉ envelope
0x270F, // ✏ pencil
0x2712, // ✒ pen
0x2692, 0x2694, 0x2696, 0x2697, 0x2699, // ⚒⚔⚖⚗⚙
0x26CF, 0x26D1, 0x26D3, // ⛏⛑⛓
0x26E9, // ⛩ shrine
0x26F1, // ⛱
0x2620, // ☠ skull&crossbones
0x2622, 0x2623, // ☢☣
0x269C, // ⚜
]);
// iOS Safari only lets Web Speech run inside a user gesture, so event-driven game
// voice lines never play reliably. Disable TTS (and its priming) on iOS entirely.
const IS_IOS = typeof navigator !== 'undefined' &&
(/iP(hone|od|ad)/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1));
// Asset roots, relative to web/index.html.
const ASSET_ROOT = CFG.assetRoot;
class Runtime {
constructor(canvas) {
this.canvas = canvas;
const tryCtx = canvas.getContext('2d', { alpha: false, colorSpace: 'display-p3' });
this.isP3 = !!(tryCtx && tryCtx.getContextAttributes && tryCtx.getContextAttributes().colorSpace === 'display-p3');
this.ctx = tryCtx || canvas.getContext('2d', { alpha: false });
this.wasmMemory = null;
this.exports = null;
this.textDecoder = new TextDecoder('utf-8');
this.textEncoder = new TextEncoder();
// ---- graphics targets ----
// targets[0] is the main canvas context. targets[h>0] are offscreen
// canvases minted by rt_create. Each entry: { canvas, ctx }.
this.targets = [{ canvas, ctx: this.ctx }];
this.curTarget = 0;
// gfx_set_text_baseline persists the canvas2D textBaseline used by
// gfx_draw_text. Default 2 = 'top' so callers that never call the new
// ABI get the historic behaviour. SK .center alignment sets it to 1
// ('middle') so emoji glyphs centre on their anchor.
this._textBaselineMode = 2;
// TTS voice preference. tts_set_preferred_voices feeds these; the
// picker resolves to a real SpeechSynthesisVoice on first tts_speak
// and caches the result. Robotic / novelty voices excluded.
this._ttsPreferred = [];
this._ttsRobotic = [];
this._ttsFemale = [];
this._ttsVoice = null;
this._ttsPending = [];
if (typeof speechSynthesis !== 'undefined') {
// Kick the voice list — Chrome only populates on first access.
speechSynthesis.getVoices();
// Opt-in test helpers (no auto console output): window.bossmanVoices()
// lists voices on demand; window.bossmanTry('name') speaks a sample.
window.bossmanVoices = () => speechSynthesis.getVoices().map((x) => ({ name: x.name, lang: x.lang, default: x.default }));
window.bossmanTry = (frag, text) => {
const x = speechSynthesis.getVoices().find((y) => (y.name || '').toLowerCase().includes((frag || '').toLowerCase()));
const u = new SpeechSynthesisUtterance(text || 'Did you get the memo about the TPS reports?');
if (x) u.voice = x;
u.rate = 0.85; u.pitch = 0.55;
speechSynthesis.speak(u);
return x ? x.name : '(no match — default voice)';
};
// Voices populate asynchronously on every browser; reset cache and
// flush any utterances that were queued while it was still empty.
speechSynthesis.onvoiceschanged = () => {
this._ttsVoice = null;
const v = this._pickTTSVoice();
if (!v || !this._ttsPending.length) return;
const pending = this._ttsPending; this._ttsPending = [];
for (const u of pending) {
u.voice = v;
try { speechSynthesis.speak(u); } catch (_e) {}
}
};
}
// ---- handle tables (1-based; 0 means "not loaded/none") ----
this.images = [null]; // each: { source, width, height } source = drawable
this.shaders = [null]; // each: { program, uniformLocs, srcText }
this.engineNodes = []; // AVAudioEngine nodes (player/mixer)
this.glCanvas = null; // hidden WebGL2 offscreen canvas (lazily created)
this.gl = null; // WebGL2 context (lazily created)
this.glQuadVAO = null; // shared full-quad VAO for shader/lighting passes
this.glTexFromImage = new Map(); // imgId -> WebGLTexture cache
this.fonts = [null]; // each: family string ; index 0 is implicit default
this.sounds = [null]; // each: AudioBuffer
this.imageByName = new Map();
this.soundByName = new Map();
this.fontByName = new Map();
// iOS/macOS system fonts the game names by their UIFont family string but
// that ship with the OS (NOT bundled as .ttf). SpriteKit resolves these on
// device; the browser does too, since Safari/macOS carry the same families.
// Pre-register each so SKLabelNode.fontName resolves to the real face — e.g.
// the title-screen copyright is "AmericanTypewriter" — instead of falling
// through to the monospace default. Key is registered both verbatim and via
// basename(), and matching is space-insensitive (see lookupFont).
for (const [name, family] of [
['AmericanTypewriter', '"American Typewriter", "AmericanTypewriter", Courier, monospace'],
['Courier', 'Courier, "Courier New", monospace'],
['CourierNew', '"Courier New", Courier, monospace'],
['Helvetica', 'Helvetica, Arial, sans-serif'],
['HelveticaNeue', '"Helvetica Neue", Helvetica, Arial, sans-serif'],
['ArialMT', 'Arial, Helvetica, sans-serif'],
['Arial', 'Arial, Helvetica, sans-serif'],
['ChalkboardSE-Regular', '"Chalkboard SE", "Comic Sans MS", cursive'],
['MarkerFelt-Thin', '"Marker Felt", "Comic Sans MS", cursive'],
['TimesNewRomanPSMT', '"Times New Roman", Times, serif'],
['Menlo-Regular', 'Menlo, monospace'],
]) {
this.fonts.push(family);
const h = this.fonts.length - 1;
this.fontByName.set(name, h);
this.fontByName.set(name.replace(/[\s-]/g, '').toLowerCase(), h);
}
this.texts = new Map(); // text assets (levels.json, ...) for asset_text
// Rasterized-glyph cache: maps "font|size|baseline|rgba|string" to a small
// offscreen canvas holding the rendered text. Canvas2D color-emoji fillText
// is very expensive; an emoji-heavy scene (dozens of SKLabelNode actors)
// re-rasterizing every frame collapses to single-digit fps. We rasterize a
// string once and blit the bitmap thereafter — same output, ~free redraws.
this._glyphCache = new Map();
this._glyphCacheCap = 2048; // headroom so an emoji-heavy scene never evicts a still-visible glyph (re-raster = hiccup)
// ---- audio ----
this.audioCtx = null;
this.voices = new Map(); // voice handle -> { source, gain, base, state }
this.nextVoice = 1;
this.duckFactor = 1; // 1 = normal; <1 ducks music/SFX while a TTS voice speaks
// ---- input ----
this.pressed = new Set(); // DOM codes currently down
this.mouseDown = [false, false]; // [left, right]
this.mouseX = 0;
this.mouseY = 0;
// logical->backing-store transform (set by layout())
this.baseScale = 1;
this.offX = 0;
this.offY = 0;
this.events = []; // queued {type,a,b,c,d}
// Gamepad / USB arcade joystick state. We poll navigator.getGamepads()
// once per frame and remember the previous button states so we can detect
// edges and emit synthetic keydown/keyup events when key-mapping is on.
this.gpEnabled = true; // master poll switch
this.gpMapToKeys = true; // synthesize arrow/space keys from d-pad/stick
this.gpAxisDeadzone = 0.35; // threshold above which a stick "presses" a direction
this.gpPrev = [null, null, null, null]; // last frame's {buttons:[0/1], axesDir:{up,down,left,right}}
// default font handle 0 maps to a monospace stack
this.defaultFontFamily = 'JetBrainsMono-Bold, ui-monospace, Menlo, monospace';
}
// --------------------------------------------------------------------------
// memory helpers (re-create the view each call: wasm memory may have grown)
// --------------------------------------------------------------------------
dv() { return new DataView(this.wasmMemory.buffer); }
u8(ptr, len) { return new Uint8Array(this.wasmMemory.buffer, ptr, len); }
cstr(ptr, len) { return this.textDecoder.decode(this.u8(ptr, len)); }
// Force colour emoji on text-default codepoints (append U+FE0F) so e.g. ⛰
// doesn't render as a white silhouette. Fast-pathed: only rebuilds the string
// when it actually contains a candidate scalar.
emojify(s) {
if (!s || s.length < 1) return s;
let hit = false;
for (let i = 0; i < s.length; i++) {
const cp = s.codePointAt(i);
if (cp > 0xFFFF) i++;
if (TEXT_DEFAULT_EMOJI.has(cp)) { hit = true; break; }
}
if (!hit) return s;
const cps = Array.from(s);
let out = '';
for (let i = 0; i < cps.length; i++) {
out += cps[i];
if (TEXT_DEFAULT_EMOJI.has(cps[i].codePointAt(0)) && cps[i + 1] !== '️') out += '️';
}
return out;
}
// Debug HUD: live FPS, per-frame image/text draw counts, and wasm frame() ms.
// Draws straight on the backing canvas in device pixels (identity transform)
// after the wasm render, so it sits on top of everything.
drawFpsOverlay() {
const c = this.ctx;
if (!c) return;
// Compose the HUD from the SKView flags the kit pushed via dbg_set_overlays:
// bit0 = showsFPS, bit1 = showsDrawCount. Nothing on -> draw nothing (Apple
// default of showsFPS/showsDrawCount = false).
const flags = this._overlayFlags | 0;
if (flags === 0) return;
const fps = this._fps || 0;
const parts = [];
const fm = this._frameMs || 0, mi = this._msImg || 0, mt = this._msTxt || 0;
const avgMs = this._frameSamples ? this._frameSum / this._frameSamples : fm;
if (flags & 1) parts.push(`FPS ${fps.toFixed(0)} frame ${fm.toFixed(1)} min ${(this._minFrameMs||0).toFixed(1)} avg ${avgMs.toFixed(1)} max ${(this._maxFrameMs||0).toFixed(1)}ms`);
if (flags & 2) parts.push(`img ${this._dcImg|0}/${mi.toFixed(1)}ms txt ${this._dcTxt|0}/${mt.toFixed(1)}ms rest ${Math.max(0,fm-mi-mt).toFixed(1)}ms`);
// Leak watch: wasm linear memory is monotonic (only grows) — steady climb during
// play = Swift-side accumulation (nodes/particles not freed). glyph = JS cache size.
if (flags & 2) {
const memMB = (this.wasmMemory && this.wasmMemory.buffer) ? (this.wasmMemory.buffer.byteLength / 1048576).toFixed(0) : '?';
const imgN = this.images ? (Array.isArray(this.images) ? this.images.filter(Boolean).length : Object.keys(this.images).length) : 0;
parts.push(`mem ${memMB}MB cvs ${((this._canvasBytes||0)/1048576).toFixed(0)}MB glyph ${this._glyphCache ? this._glyphCache.size : 0} img# ${imgN}`);
}
c.save();
c.setTransform(1, 0, 0, 1, 0, 0);
c.globalAlpha = 1;
c.filter = 'none';
c.font = '16px monospace';
c.textAlign = 'right'; // Apple-style: stacked + right-aligned in the BOTTOM-RIGHT
c.textBaseline = 'top';
const lineH = 19, pad = 6;
const cw = c.canvas.width, ch = c.canvas.height;
let maxW = 0;
for (const p of parts) { const pw = c.measureText(p).width; if (pw > maxW) maxW = pw; }
const boxW = maxW + 16, boxH = parts.length * lineH + 8;
const bx = cw - boxW - pad, by = ch - boxH - pad;
c.fillStyle = 'rgba(0,0,0,0.7)';
c.fillRect(bx, by, boxW, boxH);
c.fillStyle = fps >= 50 ? '#3f6' : fps >= 30 ? '#fd0' : '#f44';
const rx = cw - pad - 8;
for (let i = 0; i < parts.length; i++) c.fillText(parts[i], rx, by + 4 + i * lineH);
c.restore();
}
// Rasterize a text string into its own offscreen canvas at DEVICE resolution
// (logical ink bounds × ds), so blitting it back through the baseScale
// transform at logical size stays pixel-crisp on retina/hi-DPR backings — the
// prior 1x rasterize-then-upscale was the source of the emoji blur. Returns
// { canvas, ox, oy, dw, dh } where (ox,oy) is the LOGICAL offset from the draw
// pen (x,y) to where the canvas top-left should land, and (dw,dh) is the
// canvas's LOGICAL footprint, so drawImage(canvas, x+ox, y+oy, dw, dh)
// reproduces fillText(s,x,y) under the given baseline mode (0=alphabetic,
// 1=middle, 2=top, 3=bottom). ds is the device-pixel scale (>=1). Returns
// null for empty strings or on any rasterize failure (caller falls back).
_rasterizeText(font, s, sizePx, mode, rgba, ds) {
if (!s) return null;
const meas = this.ctx2d();
this.applyFont(meas, font, sizePx, 0);
const m = meas.measureText(s);
// Logical ink metrics (fall back to em-box estimates when the engine omits
// the actualBoundingBox* fields, e.g. very old Safari).
const ascent = (m.actualBoundingBoxAscent != null && isFinite(m.actualBoundingBoxAscent)) ? m.actualBoundingBoxAscent : sizePx * 0.9;
const descent = (m.actualBoundingBoxDescent != null && isFinite(m.actualBoundingBoxDescent)) ? m.actualBoundingBoxDescent : sizePx * 0.3;
const left = (m.actualBoundingBoxLeft != null && isFinite(m.actualBoundingBoxLeft)) ? m.actualBoundingBoxLeft : 0;
const right = (m.actualBoundingBoxRight != null && isFinite(m.actualBoundingBoxRight)) ? m.actualBoundingBoxRight : m.width;
const pad = 2; // logical pad around the ink
const wL = Math.max(1, Math.ceil(left + right) + pad * 2); // logical canvas w
const hL = Math.max(1, Math.ceil(ascent + descent) + pad * 2); // logical canvas h
// Device-pixel canvas size; clamp so a runaway scale can never allocate a
// multi-GB bitmap (8192 matches the SVG raster cap).
const wD = Math.max(1, Math.min(Math.ceil(wL * ds), 8192));
const hD = Math.max(1, Math.min(Math.ceil(hL * ds), 8192));
let cv, cc;
try {
cv = document.createElement('canvas');
cv.width = wD; cv.height = hD;
cc = cv.getContext('2d');
if (!cc) return null;
} catch (_e) { return null; }
// Draw at device scale: everything below is in logical units, pre-scaled.
cc.setTransform(ds, 0, 0, ds, 0, 0);
this.applyFont(cc, font, sizePx, 0);
cc.textAlign = 'left';
cc.textBaseline = 'alphabetic';
cc.fillStyle = this.css(rgba);
const penX = pad + left; // logical pen x inside canvas (ink left at pad)
const penY = pad + ascent; // logical alphabetic baseline inside canvas
try { cc.fillText(s, penX, penY); } catch (_e) { return null; }
// baselineY = where the alphabetic baseline sits relative to the draw pen y,
// per the same rules the direct gfx_draw_text path uses (logical units).
let baselineY;
switch (mode) {
case 1: baselineY = (ascent - descent) / 2; break; // middle (visual centre)
case 2: baselineY = ascent; break; // top
case 3: baselineY = -descent; break; // bottom
default: baselineY = 0; // alphabetic
}
return { canvas: cv, ox: -left - pad, oy: baselineY - penY, dw: wL, dh: hL };
}
// ==========================================================================
// WASI preview1 (only what the binary imports)
// ==========================================================================
wasiImports() {
const WASI_EBADF = 8;
const impl = {
fd_write: (fd, iovsPtr, iovsLen, nwrittenPtr) => {
const dv = this.dv();
const parts = [];
let total = 0;
for (let i = 0; i < iovsLen; i++) {
const base = iovsPtr + i * 8;
const ptr = dv.getUint32(base, true);
const len = dv.getUint32(base + 4, true);
parts.push(this.textDecoder.decode(this.u8(ptr, len)));
total += len;
}
const text = parts.join('').replace(/\n$/, '');
if (text.length) (fd === 2 ? console.error : console.log)('[wasm] ' + text);
dv.setUint32(nwrittenPtr, total, true);
return 0;
},
fd_read: (_fd, _iovsPtr, _iovsLen, nreadPtr) => {
this.dv().setUint32(nreadPtr, 0, true);
return 0;
},
fd_close: () => 0,
fd_seek: (_fd, _offLo, _offHi, _whence, newOffsetPtr) => {
// write a zeroed 64-bit offset so callers never read garbage
const dv = this.dv();
dv.setUint32(newOffsetPtr, 0, true);
dv.setUint32(newOffsetPtr + 4, 0, true);
return 0;
},
fd_prestat_get: () => WASI_EBADF,
fd_prestat_dir_name: () => WASI_EBADF,
fd_fdstat_get: (_fd, statPtr) => {
// zero the fdstat (24 bytes) so libc sees a benign descriptor
const dv = this.dv();
for (let i = 0; i < 24; i++) dv.setUint8(statPtr + i, 0);
return 0;
},
fd_fdstat_set_flags: (_fd, _flags) => 0,
fd_filestat_get: (_fd, statPtr) => {
// zero the 64-byte WASI filestat so callers see a benign, empty descriptor
const dv = this.dv();
for (let i = 0; i < 64; i++) dv.setUint8(statPtr + i, 0);
return 0;
},
path_filestat_get: (_fd, _flags, _pathPtr, _pathLen, statPtr) => {
// no virtual filesystem: report ENOENT(44), same as path_open
const dv = this.dv();
for (let i = 0; i < 64; i++) dv.setUint8(statPtr + i, 0);
return 44;
},
// FS syscalls that write an out-pointer: zero it so callers never read garbage.
fd_pread: (_fd, _iovsPtr, _iovsLen, _offLo, _offHi, nreadPtr) => { this.dv().setUint32(nreadPtr, 0, true); return 0; },
fd_readdir: (_fd, _buf, _bufLen, _cookieLo, _cookieHi, bufusedPtr) => { this.dv().setUint32(bufusedPtr, 0, true); return 0; },
fd_tell: (_fd, offsetPtr) => { const dv = this.dv(); dv.setUint32(offsetPtr, 0, true); dv.setUint32(offsetPtr + 4, 0, true); return 0; },
path_readlink: (_fd, _p, _pl, _buf, _bufLen, bufusedPtr) => { this.dv().setUint32(bufusedPtr, 0, true); return 44; },
environ_sizes_get: (countPtr, sizePtr) => {
const dv = this.dv();
dv.setUint32(countPtr, 0, true);
dv.setUint32(sizePtr, 0, true);
return 0;
},
environ_get: () => 0,
args_sizes_get: (countPtr, sizePtr) => {
const dv = this.dv();
dv.setUint32(countPtr, 0, true);
dv.setUint32(sizePtr, 0, true);
return 0;
},
args_get: () => 0,
// No virtual filesystem: opening any path fails with ENOENT(44). The Swift
// runtime references path_open but does not actually open files here.
path_open: () => 44,
clock_time_get: (_id, _precision, timePtr) => {
const ns = BigInt(Math.round(performance.now() * 1e6));
this.dv().setBigUint64(timePtr, ns, true);
return 0;
},
// Clock resolution in nanoseconds. The Swift concurrency runtime (linked
// the moment any @MainActor executor code runs, e.g. MainActor.assumeIsolated)
// imports this; report performance.now()'s ~1us granularity.
clock_res_get: (_id, resPtr) => {
this.dv().setBigUint64(resPtr, 1000n, true);
return 0;
},
random_get: (ptr, len) => {
const bytes = this.u8(ptr, len);
crypto.getRandomValues(bytes);
return 0;
},
proc_exit: (code) => { throw new Error('wasm proc_exit(' + code + ')'); },
poll_oneoff: (_in, _out, _nsub, neventsPtr) => {
this.dv().setUint32(neventsPtr, 0, true);
return 0;
},
sched_yield: () => 0,
};
// Any WASI fn not explicitly shimmed above resolves to a benign no-op returning 0 (success),
// so Foundation pulling in extra fs/time syscalls (fd_filestat_set_size, path_rename, ...) never
// breaks linking. The output-writing ones (fd_read/seek/tell/pread/readdir, *_filestat_get) are
// shimmed explicitly so callers never read uninitialized out-pointers.
return new Proxy(impl, { get: (t, p) => (p in t ? t[p] : () => 0) });
}
// ==========================================================================
// env imports (platform/web/abi.h)
// ==========================================================================
envImports() {
return {
// C++ exception runtime stubs. With -fno-exceptions on Box2DBridge
// these shouldn't be reached, but they keep wasm instantiation alive
// if a stray throw slips in (third-party C++ deps, etc.). __cxa_throw
// surfaces with a console error + JS throw so a real C++ throw still
// becomes visible in DevTools.
__cxa_allocate_exception: (_size) => 0,
__cxa_throw: (_ptr, _type, _dtor) => {
console.error('[boss] __cxa_throw reached — uncaught C++ exception in wasm');
throw new Error('uncaught C++ exception from wasm');
},
// ---- logging ----
js_log: (ptr, len) => {
console.log('%c[boss] ' + this.cstr(ptr, len), 'color:#e6b800');
},
// ---- target + transform/blend ----
gfx_target: (target) => {
this.curTarget = (target > 0 && target < this.targets.length) ? target : 0;
},
gfx_clear: (rgba) => {
const c = this.ctx2d();
c.setTransform(1, 0, 0, 1, 0, 0);
c.globalAlpha = 1;
c.globalCompositeOperation = 'source-over';
c.fillStyle = this.css(rgba);
c.fillRect(0, 0, c.canvas.width, c.canvas.height);
// The screen target draws in logical (1184x644) coords scaled up to the
// hi-res backing store (crisp at any size, letterboxed). Render textures
// are logical-sized, so they stay at identity.
if (this.curTarget === 0) c.setTransform(this.baseScale, 0, 0, this.baseScale, this.offX, this.offY);
},
gfx_save: () => this.ctx2d().save(),
gfx_restore: () => this.ctx2d().restore(),
gfx_translate: (x, y) => this.ctx2d().translate(x, y),
gfx_snap_translation: () => {
// Round the live transform's translation to whole device pixels. The
// scale stays fractional (full-res), but a scrolling camera now shifts
// content by whole pixels, so a repeating tile grid keeps a constant
// sub-pixel phase instead of shimmering on low-DPR desktops.
const c = this.ctx2d();
const m = c.getTransform();
m.e = Math.round(m.e);
m.f = Math.round(m.f);
c.setTransform(m);
},
gfx_scale: (sx, sy) => this.ctx2d().scale(sx, sy),
gfx_rotate: (deg) => this.ctx2d().rotate(deg * Math.PI / 180),
gfx_set_alpha: (a) => { this.ctx2d().globalAlpha = a; },
gfx_set_line_style: (join, cap, miter) => {
const c = this.ctx2d();
c.lineJoin = join === 1 ? 'round' : join === 2 ? 'bevel' : 'miter';
c.lineCap = cap === 1 ? 'round' : cap === 2 ? 'square' : 'butt';
c.miterLimit = miter > 0 ? miter : 10;
},
gfx_set_blend: (mode) => {
const c = this.ctx2d();
c.globalAlpha = 1; // SFML carries alpha in the fill/vertex colour; never inherit a leaked globalAlpha (it washed opaque shape fills out)
switch (mode) {
case 1: c.globalCompositeOperation = 'lighter'; break; // SKBlendMode.add
case 2: c.globalCompositeOperation = 'multiply'; break; // SKBlendMode.multiply
case 3: c.globalCompositeOperation = 'screen'; break; // SKBlendMode.screen (glowing particles, e.g. the white-hole)
default: c.globalCompositeOperation = 'source-over'; break;
}
},
// One-shot Apple colorBlendFactor tint for the NEXT gfx_draw_image (set by
// SKEmitterNode.draw per particle). Stores the RAW particle colour + bf so
// gfx_draw_image can do tex*(1-bf)+colour*bf (source-atop) — correct for
// COLOURED emoji textures, unlike the rgba multiply. bf<=0 clears it.
gfx_set_tint: (r, g, b, bf) => {
this._partTint = bf > 0 ? { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255), bf: Math.min(1, bf) } : null;
},
// ---- primitives ----
gfx_fill_rect: (x, y, w, h, rgba) => {
const c = this.ctx2d();
c.fillStyle = this.css(rgba);
c.fillRect(x, y, w, h);
},
gfx_stroke_rect: (x, y, w, h, thickness, rgba) => {
const c = this.ctx2d();
c.lineWidth = thickness;
c.strokeStyle = this.css(rgba);
c.strokeRect(x, y, w, h);
},
gfx_fill_circle: (cx, cy, r, rgba) => {
// ctx.arc throws IndexSizeError on a negative/NaN radius (a physics body
// whose sprite texture came in undersized can compute a negative radius).
// Guard so one bad body can't abort the whole frame.
if (!(r > 0) || !isFinite(cx) || !isFinite(cy)) return;
const c = this.ctx2d();
c.fillStyle = this.css(rgba);
c.beginPath();
c.arc(cx, cy, r, 0, Math.PI * 2);
c.fill();
},
gfx_stroke_circle: (cx, cy, r, thickness, rgba) => {
if (!(r > 0) || !isFinite(cx) || !isFinite(cy)) return;
const c = this.ctx2d();
c.lineWidth = thickness;
c.strokeStyle = this.css(rgba);
c.beginPath();
c.arc(cx, cy, r, 0, Math.PI * 2);
c.stroke();
},
gfx_fill_poly: (xyPtr, npts, rgba) => {
if (npts < 2) return;
const c = this.ctx2d();
const dv = this.dv();
c.fillStyle = this.css(rgba);
c.beginPath();
c.moveTo(dv.getFloat32(xyPtr, true), dv.getFloat32(xyPtr + 4, true));
for (let i = 1; i < npts; i++) {
c.lineTo(dv.getFloat32(xyPtr + i * 8, true), dv.getFloat32(xyPtr + i * 8 + 4, true));
}
c.closePath();
c.fill();
},
gfx_stroke_poly: (xyPtr, npts, closed, thickness, rgba) => {
if (npts < 2) return;
const c = this.ctx2d();
const dv = this.dv();
c.strokeStyle = this.css(rgba);
c.lineWidth = thickness;
c.lineJoin = 'round';
c.beginPath();
c.moveTo(dv.getFloat32(xyPtr, true), dv.getFloat32(xyPtr + 4, true));
for (let i = 1; i < npts; i++) {
c.lineTo(dv.getFloat32(xyPtr + i * 8, true), dv.getFloat32(xyPtr + i * 8 + 4, true));
}
if (closed) c.closePath();
c.stroke();
},
// ---- textured quad ----
gfx_draw_image: (img, sx, sy, sw, sh, dx, dy, dw, dh, rgba) => {
this._dcImg = (this._dcImg | 0) + 1;
const _t0i = performance.now(); try { // HUD ms-profile (img time)
// Apple colorBlendFactor tint (set by gfx_set_tint immediately before this
// call, per particle): consume and clear it FIRST — before the missing-rec
// early return below — so a draw whose texture isn't loaded can't strand the
// tint and bleed it onto the next frame's background ("whole screen brown").
const pt = this._partTint; this._partTint = null;
const rec = this.images[img];
if (!rec) return;
const c = this.ctx2d();
const a = (rgba & 0xFF) / 255;
if (pt) {
const blended = this._blendTint(rec, sx, sy, sw, sh, dw, dh, pt, c);
if (blended) {
const pa = c.globalAlpha; c.globalAlpha = pa * a;
try { c.drawImage(blended, dx, dy, dw, dh); } catch (_e) {}
c.globalAlpha = pa;
return;
}
}
const prevAlpha = c.globalAlpha;
c.globalAlpha = prevAlpha * a;
// RGB tint (SpriteKit colorBlendFactor / particleColor): when the tint
// is not white, multiply the texture's colours by it (masked by the
// texture's own alpha) so SKEmitterNode particles take their .sks colour
// instead of the raw texture. White (0xFFFFFFxx) means 'no tint' -> the
// existing fast paths below. Applies to BOTH raster and SVG textures
// (the hit particle 🥛🍞🥚 ships as SVG) so the colour is actually visible.
const tr = (rgba >>> 24) & 0xFF, tg = (rgba >>> 16) & 0xFF, tb = (rgba >>> 8) & 0xFF;
const tinted = (tr !== 255 || tg !== 255 || tb !== 255);
try {
// Vector (SVG) textures rasterize on demand at the exact device-pixel
// footprint of the destination rect, so they stay crisp at any camera
// zoom or devicePixelRatio with zero quality loss. Embedded <image>
// bitmaps, <clipPath> and <mask> are rendered by the browser's own
// SVG engine, so nothing is flattened. See drawSVG().
if (rec.svg) {
if (tinted) {
const tcv = this._tintSVG(rec, dw, dh, tr, tg, tb, c);
if (tcv) { c.drawImage(tcv, dx, dy, dw, dh); c.globalAlpha = prevAlpha; return; }
}
this.drawSVG(c, rec, sx, sy, sw, sh, dx, dy, dw, dh);
c.globalAlpha = prevAlpha;
return;
}
if (tinted) {
const rcv = this._tintRaster(rec, sx, sy, sw, sh, tr, tg, tb);
if (rcv) { c.drawImage(rcv, dx, dy, dw, dh); c.globalAlpha = prevAlpha; return; }
}
// sw or sh < 0 is our "use the full source" sentinel — SKSpriteNode
// passes -1/-1 when no sub-rect is set, and the 9-arg drawImage
// throws on negative source dimensions, so we route to the 5-arg
// form (dst-only). Before this fix, the catch below silently ate
// the throw and the sprite rendered as nothing.
if (sw < 0 || sh < 0) {
c.drawImage(rec.source, dx, dy, dw, dh);
} else {
c.drawImage(rec.source, sx, sy, sw, sh, dx, dy, dw, dh);
}
} catch (_e) { /* zero-size src/dst */ }
c.globalAlpha = prevAlpha;
} finally { this._msImg = (this._msImg || 0) + (performance.now() - _t0i); }
},
// ---- text ----
txt_width: (font, ptr, len, sizePx, letterSpacing) => {
const c = this.ctx2d();
const s = this.emojify(this.cstr(ptr, len));
this.applyFont(c, font, sizePx, letterSpacing);
let w = c.measureText(s).width;
if (!this.hasLetterSpacing && letterSpacing) {
w += letterSpacing * Math.max(0, [...s].length - 1);
}
return Math.ceil(w);
},
gfx_set_text_baseline: (mode) => {
// Persisted on the Game so a draw call can read it back. 0=alphabetic,
// 1=middle (visual centre — what SK .center wants), 2=top, 3=bottom.
// Defaults back to 'top' after every draw to preserve historic
// behaviour for callers that don't touch it.
this._textBaselineMode = mode | 0;
},
gfx_draw_text: (font, ptr, len, x, y, sizePx, rgba, letterSpacing) => {
this._dcTxt = (this._dcTxt | 0) + 1;
const _t0t = performance.now(); try { // HUD ms-profile (text time)
const c = this.ctx2d();
const s = this.emojify(this.cstr(ptr, len));
if (!s) return;
const mode = this._textBaselineMode | 0;
// ---- DPR-aware glyph cache -----------------------------------------
// Canvas2D color-emoji fillText is the per-frame hot path; an emoji-heavy
// scene re-rasterizing every frame collapses to single-digit fps. We
// rasterize each (font|size|baseline|rgba|spacing|deviceScale|string)
// once into an offscreen canvas AT DEVICE RESOLUTION, then blit it at
// LOGICAL size. The live transform here includes the kit's scale(1,-1)
// un-flip, so we read the per-axis scale magnitude (flip-safe, same as
// drawSVG) to learn how many device pixels one logical unit covers, and
// rasterize at that resolution — no upscale, no blur on retina.
// Skip the cache only when we'd need the per-char letter-spacing loop
// (no native letterSpacing AND a non-zero spacing); that path falls
// through to the verbatim direct-fillText code below.
const spaced = !this.hasLetterSpacing && !!letterSpacing;
if (!spaced) {
const m = c.getTransform();
// Per-axis device scale (folds in baseScale+DPR+zoom); hypot ignores
// the sign of the flip. Quantize to 1/4-px steps so a steady camera
// hits the same cache entry every frame, and clamp to a sane ceiling.
const sxDev = Math.hypot(m.a, m.b) || 1;
const syDev = Math.hypot(m.c, m.d) || 1;
const dsRaw = Math.max(sxDev, syDev);
const ds = Math.max(1, Math.min(Math.round(dsRaw * 4) / 4, 16));
const key = font + '|' + sizePx + '|' + mode + '|' + (rgba >>> 0) +
'|' + (letterSpacing || 0) + '|' + ds + '|' + s;
let rec = this._glyphCache.get(key);
if (rec !== undefined) {
this._glyphCache.delete(key); this._glyphCache.set(key, rec); // LRU bump
} else {
rec = this._rasterizeText(font, s, sizePx, mode, rgba, ds) || null;
this._glyphCache.set(key, rec);
// Evict the least-recently-used entries when over the cap.
while (this._glyphCache.size > this._glyphCacheCap) {
const oldest = this._glyphCache.keys().next().value;
this._glyphCache.delete(oldest);
}
}
if (rec) {
// Blit at logical size; the live transform scales it back up 1:1 to
// the device pixels it was rasterized at, so it stays crisp.
try { c.drawImage(rec.canvas, x + rec.ox, y + rec.oy, rec.dw, rec.dh); }
catch (_e) { /* zero-size; ignore */ }
return;
}
// rec === null: rasterize failed — fall through to direct fillText.
}
// ---- direct fillText fallback (per-char spacing or cache miss) ------
this.applyFont(c, font, sizePx, letterSpacing);
c.textAlign = 'left';
c.fillStyle = this.css(rgba);
// Visual centring (mode 1): Canvas2D 'middle' uses the em-box
// geometric centre, but emoji glyphs sit a couple of pixels above
// the em centre, so they read as too-high. Compute the actual ink
// bounds via measureText and offset the alphabetic baseline so
// the visible-glyph centroid lands on the requested y.
if (mode === 1) {
c.textBaseline = 'alphabetic';
const m = c.measureText(s);
const ascent = m.actualBoundingBoxAscent || sizePx * 0.8;
const descent = m.actualBoundingBoxDescent || sizePx * 0.2;
const yb = y + (ascent - descent) / 2;
if (this.hasLetterSpacing || !letterSpacing) {
c.fillText(s, x, yb);
} else {
let cx = x;
for (const ch of s) {
c.fillText(ch, cx, yb);
cx += c.measureText(ch).width + letterSpacing;
}
}
return;
}
c.textBaseline =
mode === 2 ? 'top' :
mode === 3 ? 'bottom' :
mode === 0 ? 'alphabetic' : 'top';
if (this.hasLetterSpacing || !letterSpacing) {
c.fillText(s, x, y);
} else {
let cx = x;
for (const ch of s) {
c.fillText(ch, cx, y);
cx += c.measureText(ch).width + letterSpacing;
}
}
} finally { this._msTxt = (this._msTxt || 0) + (performance.now() - _t0t); }
},
// ---- images / fonts / render textures ----
img_by_name: (ptr, len) => {
const name = this.cstr(ptr, len);
return this.lookupImage(name);
},
img_from_rgba: (ptr, w, h) => {
const bytes = this.u8(ptr, w * h * 4).slice();
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const cc = cv.getContext('2d');
const id = new ImageData(new Uint8ClampedArray(bytes.buffer), w, h);
cc.putImageData(id, 0, 0);
this.images.push({ source: cv, width: w, height: h });
return this.images.length - 1;
},
img_width: (img) => { const r = this.images[img]; return r ? r.width : 0; },
img_height: (img) => { const r = this.images[img]; return r ? r.height : 0; },
font_by_name: (ptr, len) => {
const name = this.cstr(ptr, len);
return this.lookupFont(name);
},
asset_exists: (ptr, len) => {
const name = this.cstr(ptr, len);
const base = this.basename(name);
const has = (m) => m.has(name) || m.has(base);
return (has(this.soundByName) || has(this.imageByName) ||
has(this.fontByName) || this.texts.has(name) || this.texts.has(base)) ? 1 : 0;
},
asset_text: (ptr, nlen, bufPtr, cap) => {
const name = this.cstr(ptr, nlen);
const s = this.texts.get(name);
if (s === undefined) return -1;
const bytes = this.textEncoder.encode(s);
if (cap > 0 && bufPtr) {
const n = Math.min(bytes.length, cap);
this.u8(bufPtr, n).set(bytes.subarray(0, n));
}
return bytes.length;
},
rt_create: (w, h) => {
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const cc = this.makeCtx(cv);
this.targets.push({ canvas: cv, ctx: cc });
return this.targets.length - 1;
},
rt_image: (rt) => {
const t = this.targets[rt];
if (!t) return 0;
if (t.imageHandle) return t.imageHandle;
this.images.push({ source: t.canvas, width: t.canvas.width, height: t.canvas.height });
t.imageHandle = this.images.length - 1;
return t.imageHandle;
},
// ---- audio ----
snd_from_samples: (ptr, frames, channels, rate) => {
const ctx = this.ensureAudio();
if (!ctx || frames <= 0 || channels <= 0) return 0;
const total = frames * channels;
const dv = this.dv();
const buf = ctx.createBuffer(channels, frames, rate);
for (let ch = 0; ch < channels; ch++) {
const out = buf.getChannelData(ch);
for (let f = 0; f < frames; f++) {
const s = dv.getInt16(ptr + (f * channels + ch) * 2, true);
out[f] = s < 0 ? s / 32768 : s / 32767;
}
}
this.sounds.push(buf);
return this.sounds.length - 1;
},
snd_by_name: (ptr, len) => {
const name = this.cstr(ptr, len);
return this.lookupSound(name);
},
// Build an AudioBuffer from raw Float32 PCM samples in wasm memory.
// Used by bossman-web's SoundManager to play the procedurally-
// synthesized background loop from bossman-apple. Returns a handle
// compatible with snd_play.
snd_create_pcm: (samplesPtr, frameCount, sampleRate) => {
const ctx = this.ensureAudio();
if (!ctx || frameCount <= 0) return 0;
const rate = sampleRate > 0 ? sampleRate : ctx.sampleRate;
const buf = ctx.createBuffer(1, frameCount, rate);
const dst = buf.getChannelData(0);
const src = new Float32Array(this.wasmMemory.buffer, samplesPtr, frameCount);
dst.set(src);
this.sounds.push(buf);
return this.sounds.length - 1;
},
snd_play: (buffer, volume, loop) => {
const ctx = this.ensureAudio();
const buf = this.sounds[buffer];
if (!ctx || !buf) return 0;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = !!loop;
const gain = ctx.createGain();
const base = Math.max(0, Math.min(1, volume / 100));
gain.gain.value = base * this.duckFactor;
src.connect(gain).connect(ctx.destination);
const handle = this.nextVoice++;
const voice = { source: src, gain, base, state: 1 };
this.voices.set(handle, voice);
src.onended = () => {
voice.state = 0;
this.voices.delete(handle);
};
src.start();
return handle;
},
snd_stop: (voice) => {
const v = this.voices.get(voice);
if (!v) return;
try { v.source.onended = null; v.source.stop(); } catch (_e) {}
v.state = 0;
this.voices.delete(voice);
},
snd_set_volume: (voice, volume) => {
const v = this.voices.get(voice);
if (!v || !this.audioCtx) return;
v.base = Math.max(0, Math.min(1, volume / 100));
v.gain.gain.setTargetAtTime(v.base * this.duckFactor, this.audioCtx.currentTime, 0.02);
},
snd_status: (voice) => {
const v = this.voices.get(voice);
return v ? v.state : 0;
},
snd_pause_all: () => {
if (this.audioCtx && this.audioCtx.state === 'running') this.audioCtx.suspend();