-
-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathcoincommunities.js
More file actions
5107 lines (4841 loc) · 235 KB
/
Copy pathcoincommunities.js
File metadata and controls
5107 lines (4841 loc) · 235 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
// Coin Communities, 3D metaverse client (the /play scene).
//
// Each pump.fun coin is its own multiplayer 3D world. You pick an avatar (or
// bring your own / your 3D agent), choose a coin in the lobby, and drop into
// that coin's community: a shared space where everyone walks around as real
// GLB avatars, emotes, and chats. The server (WalkRoom, keyed by coin) is
// authoritative for position/avatar/chat; this client predicts local movement
// and interpolates everyone else.
//
// Built on the same proven engine as /walk (GLTF avatars + AnimationManager +
// Colyseus), reused here so a coin community is a first-class 3D space.
import {
Scene, WebGLRenderer, PerspectiveCamera, Group, Vector3, SRGBColorSpace,
Mesh, MeshStandardMaterial, MeshBasicMaterial, CircleGeometry, RingGeometry,
CylinderGeometry, PlaneGeometry,
TextureLoader, DoubleSide,
PointLight,
Raycaster, Vector2,
} from 'three';
import { captureSceneCanvas } from './scene-capture.js';
import { AnimationManager } from '../animation-manager.js';
import { CommunityNet } from './community-net.js';
import { CommunityUI } from './coincommunities-ui.js';
import { createWorldEnvironment, seedFromString } from './world-env.js';
import { createDistrict } from './district.js';
import { DISTRICT, clampToBounds } from './world-zones.js';
import { PhysicsWorld } from '../physics/physics-world.js';
import { detectProfile, createFrameWatchdog } from '../club-perf.js';
import { applyCinematicDefaults, loadEnvironment } from '../shared/cinematic-render.js';
import {
createFrameGovernor, trackWindowFocus, getPowerSaver, onPowerSaverChange,
FPS_ACTIVE, FPS_IDLE, FPS_SAVER,
} from '../shared/frame-governor.js';
import { createDayNightCycle } from './day-night.js';
import { worldClock } from '../shared/world-clock.js';
import { createCameraModeController, CAMERA_MODE_LABELS, CAMERA_MODE_FOV } from './camera-modes.js';
import { createChartScreen } from './chart-screen.js';
import { makeScreenCanvas, makeScreenTexture, screenMaterial, screenAnisotropy } from './screen-texture.js';
import { mountOracleRibbon } from './oracle-ribbon.js';
import { MarketReactor } from './market-reactor.js';
import {
VoxelWorld, createBuildHud, parseKey, keyOf, MAX_BLOCKS, BLOCK,
COMPOSITE_PIECES, compositeCells,
} from './build-voxels.js';
import { WorldObjects, PropGhost, propDef, registerUploadedProp } from './world-objects.js';
// P3.1: durable per-world build persistence (Postgres index + R2 blob), the same
// store the authoritative room writes through. See src/game/world-persist.js for
// which side is the writer when.
import { WorldBuildStore, worldIdForCoin, docObjects } from './world-persist.js';
import {
MAX_WORLD_OBJECTS, MAX_OBJECTS_PER_PLAYER, OBJ_SCALE_MIN, OBJ_SCALE_MAX,
buildClearRadius,
} from '../../multiplayer/src/build-limits.js';
import { proxiedImageURL } from '../ipfs.js';
import {
loadManifest, getEmoteDefs, getAllEmoteDefs, resolveAvatarUrl, buildAvatar, releaseAvatar, playEmoteClip,
CLIP_IDLE, CLIP_WALK,
} from './avatar-rig.js';
import { GUEST_SENTINEL, uploadPendingGuestAvatar, getPlayCosmetics, setPlayCosmetics, setPlayAvatar } from './play-handoff.js';
import { AvatarSwitcher } from './avatar-switcher.js';
import { getPresenceTicket, friendsClient } from '../friends.js';
import { getMe } from '../account.js';
import { showPlayIntro, makeIntroReopener } from './play-intro.js';
import { applyLoadout } from './cosmetics-loadout.js';
import { serializeLoadout, getCosmetic } from '../../multiplayer/src/cosmetics-catalog.js';
import { AccessoryManager } from '../agent-accessories.js';
import { HOME_TOWN, isHomeTown } from './home-town.js';
// A Robinhood Chain coin is an EVM address (pump.fun mints are Solana base58).
// Every RH-chain world pins the 'hoodchain' biome (world-env.js) so the chain
// reads as a recognisable family; per-coin hue jitter (also in world-env.js)
// still keeps two RH coins from looking identical.
const isRobinhoodCoin = (mint) => /^0x[a-fA-F0-9]{40}$/.test(mint || '');
import { AgentCommerce } from './agent-commerce.js';
import { IntelKiosk } from './intel-kiosk.js';
import { WorldLife } from './npc/world-life.js';
import { isChatPanelOpen } from './npc/npc-chat.js';
import { isServicePanelOpen } from './npc/npc-services.js';
import { isAixbtPanelOpen } from './npc/npc-aixbt.js';
import { isZauthPanelOpen } from './npc/npc-zauth.js';
import { requestHolderPass, signInWithX, ensureSolanaWallet, relinkSolanaWallet, getSession, getWorldGate, setWorldGate } from '../community/town-auth.js';
import { ensurePlayAccess } from './play-gate.js';
import { hasOpenOverlay } from './a11y.js';
import { clearStoredPass, refreshPlayPass, loadStoredPass, storePass } from './play-auth.js';
import { PlaySystems } from './play-systems.js';
import { PlayActivities } from './play-activities.js';
import { WheelStation } from './wheel-station.js';
import { WarPortal } from './war-portal.js';
import { PlayOnboard } from './play-onboard.js';
import { log } from '../shared/log.js';
import { openAvatarInspector, isAvatarInspectorOpen, closeAvatarInspector } from '../shared/avatar-inspector.js';
import { createAgentDesk } from './agent-desk.js';
import { VehicleManager } from './vehicles.js';
import { CombatSystem } from './combat-system.js';
// localStorage throws in private mode and in third-party iframe contexts where
// storage is blocked, exactly the `?bg=transparent` embed case (e.g. the IBM
// x402 showcase). Guard every access so a blocked store degrades to defaults
// instead of throwing mid-boot. Same contract as the lsGet/lsSet helpers in
// play-onboard.js / play-intro.js / play-handoff.js.
function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch { /* storage disabled */ } }
// Normalise one deep-link query parameter for display. A /play link is shared
// between strangers, so `coin`, `name` and `symbol` are arbitrary attacker text.
// They are only ever written with textContent (never innerHTML), so markup in
// them is inert; what still needs handling is shape. Line breaks and control
// characters would rewrap the HUD around a value the sender chose, and an
// unbounded length would push the coin banner off screen, so both are removed
// here and the result is cut to the same limit the room server enforces.
function clampParam(value, max) {
return String(value ?? '')
.replace(/[\u0000-\u001f\u007f\u200b-\u200f\u2028\u2029\u202a-\u202e]+/g, ' ')
.trim()
.slice(0, max);
}
// A mint we are willing to open a world for: a Solana base58 address (pump.fun
// mints, including $THREE) or an EVM 0x address (Robinhood Chain coins). Anything
// else came from a typo or a mangled share link, and building a full world for it
// is worse than saying so: the player would get a real district, a totem reading
// "COMMUNITY", a room keyed on garbage, and a build layer no other player will
// ever see, with nothing anywhere telling them the link was broken.
const SOLANA_MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
function isPlausibleMint(mint) {
const v = String(mint || '').trim();
return SOLANA_MINT_RE.test(v) || EVM_ADDRESS_RE.test(v);
}
// True when the keystroke belongs to an editable surface, a DM input in the
// friends panel, a search box, a modal field. World hotkeys must never fire
// there: `b` would toggle build mode mid-word and Space would be swallowed
// before it reached the caret. `chatFocused` covers only the in-world chat bar,
// so this is the general guard for every other input the HUD can open.
function isTypingTarget(t) {
if (!t || t.nodeType !== 1) return false;
if (t.isContentEditable) return true;
const tag = t.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
// Enter and Space are the browser's activation gesture for whatever control has
// focus. Binding them globally to "open chat" and "jump" meant a keyboard-only
// player could Tab to Shop, Friends, or any emote button and press Enter, and
// the world would swallow it, so the button never fired and the HUD was
// reachable but not operable. Every other hotkey (WASD, E, Q, Z…) still runs
// while a control is focused, because movement has to keep working; only the
// two activation keys defer to the focused control.
const ACTIVATION_TARGET = 'button,a[href],select,summary,[role="button"],[role="tab"],[role="menuitem"],[role="switch"],[role="checkbox"],[role="option"]';
function isActivationTarget(t) {
if (!t || t.nodeType !== 1) return false;
return !!t.closest?.(ACTIVATION_TARGET);
}
// Reaction bar (R04): the 6 emoji available to all players.
const REACTIONS = [
{ emoji: '🎉', label: 'Celebrate' },
{ emoji: '😂', label: 'Laugh' },
{ emoji: '🔥', label: 'Fire' },
{ emoji: '❤️', label: 'Love' },
{ emoji: '👏', label: 'Clap' },
{ emoji: '🤔', label: 'Think' },
];
// Confetti palette: monochrome-ish whites + very faint warm/cool accents.
const CONFETTI_COLORS = ['#ffffff', '#e8e8e8', '#fff8e1', '#e3f2fd', '#f3e5f5', '#e8f5e9'];
// King of the Totem (R07): the hold-the-totem zone, centred on the coin totem
// (built at world (0, -12) in _buildTotem). Mirrors the server's KING_ZONE so the
// rendered ring and the authoritative scoring area are the same circle. The server
// also sends these bounds in every game:king message; this is the render default.
const KING_ZONE = { x: 0, z: -12, r: 3.5 };
// The Downtown plaza radius (matches world-zones.js DISTRICT.plazaRadius), the
// dressed circle world-env.js/district.js build around. W01: movement itself is
// no longer clamped to this disc; it's bounded by the much larger square
// DISTRICT/WORLD_BOUND (world-zones.js), which mirrors the server's own clamp so
// players can walk/drive the full district, not just the plaza.
const WORLD_RADIUS = 58;
const MOVE_SPEED = 4.2;
const RUN_SPEED = 8.0; // hold Shift to sprint
const RUN_TIMESCALE = 1.7; // speed the walk cycle up so a sprint reads as a run
const JUMP_VELOCITY = 5.5; // m/s upward kick on Space; ~1m apex under GRAVITY
const GRAVITY = 15; // m/s^2 pulling the jumper back down
const REMOTE_LERP = 0.18;
// Longest the canvas may hold its last frame while shaders pre-compile
// (_warmShaders), and how long after world entry the render-tier watchdog stays
// out of the way (_loop). See each for why.
const WARM_TIMEOUT_MS = 1200;
const WATCHDOG_GRACE_MS = 3000;
// Past this ground distance a peer's nameplate is a couple of unreadable pixels,
// so it isn't projected or written to the DOM at all. Bounds the per-frame label
// cost by how many people are NEAR you, not by how many are in the world.
const LABEL_RANGE_M = 60;
// Animation LOD thresholds for remote players (see RemotePlayer.tick). Inside
// 22m a peer fills enough of the screen that anything less than full rate is
// visible, so that band is never stepped down; past 55m a peer is a silhouette
// and 6fps of skeleton is indistinguishable from 60. Squared to keep the
// per-peer test to a multiply, since it runs for every peer every frame.
const ANIM_LOD_NEAR_SQ = 22 * 22;
const ANIM_LOD_FAR_SQ = 55 * 55;
const ANIM_LOD_MID_INTERVAL_S = 1 / 20;
const ANIM_LOD_FAR_INTERVAL_S = 1 / 6;
const JOY_DEADZONE = 0.12; // swallow tiny stick grazes so the avatar doesn't drift
const UNDO_LIMIT = 50; // how many build actions Ctrl/Cmd+Z can walk back
const LONG_PRESS_MS = 420; // hold-to-break threshold for touch (no right-click there)
const TRENDING_URL = '/api/pump/trending?limit=30';
const SEARCH_URL = '/api/pump/search';
const COIN_URL = '/api/pump/coin';
// Normalize a raw pump.fun coin (trending feed or search results, both share
// the same upstream shape) into the compact record the lobby/world consume.
function mapCoins(raw) {
const list = Array.isArray(raw) ? raw : raw.data || raw.coins || raw.items || [];
return list.map((c) => ({
mint: c.mint || c.address,
name: (c.name || '').trim() || 'Unnamed coin',
symbol: (c.symbol || '').trim(),
image: proxiedImageURL(c.image_uri || c.image || c.imageUri || c.logo || '', c.mint || c.address || ''),
marketCap: c.usd_market_cap || c.market_cap_usd || c.marketCap || 0,
})).filter((c) => c.mint);
}
// How long a bare deep link waits for the coin's identity before it gives up and
// builds the world anyway. Entry already has the sign-in gate, the manifest and
// an avatar GLB ahead of it, so this is the one place a slow upstream must never
// be allowed to hold the player on a loading screen.
const COIN_IDENTITY_TIMEOUT_MS = 6000;
// Fill in whatever a shared world link did not carry. The name/symbol/image on
// /play?coin=<mint>&name=…&symbol=…&image=… are decoration the sharer's client
// appended, and they go missing constantly: a hand-typed link, a chat client
// that truncated the query, an unfurl that kept only the mint. The mint alone
// still identifies the coin exactly, so a link without the decoration must build
// the same world as a link with it, not a nameless one titled "Community".
//
// Only blanks are filled: anything the link did carry is what every peer on that
// link already sees, so it stays authoritative here even if the feed disagrees.
function mergeCoinIdentity(coin, fetched) {
if (!fetched) return coin;
return {
...coin,
name: coin.name || fetched.name || '',
symbol: coin.symbol || fetched.symbol || '',
image: coin.image || fetched.image || '',
marketCap: coin.marketCap || fetched.marketCap || 0,
};
}
// Compact USD for the jumbotron's market-cap readout: $1.2B / $940M / $12K.
function formatUsd(n) {
const v = Number(n) || 0;
if (v <= 0) return '';
if (v >= 1e9) return '$' + (v / 1e9).toFixed(v >= 1e10 ? 0 : 1) + 'B';
if (v >= 1e6) return '$' + (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M';
if (v >= 1e3) return '$' + Math.round(v / 1e3) + 'K';
return '$' + Math.round(v);
}
// A networked peer: their own avatar rig + animation + name label + chat bubble.
class RemotePlayer {
constructor(scene, player) {
this.scene = scene;
this.rig = new Group();
this.anim = new AnimationManager();
this.targetX = player.x; this.targetY = player.y; this.targetZ = player.z; this.targetYaw = player.yaw;
this.curYaw = player.yaw; this.motion = player.motion || 'idle';
this.rig.position.set(player.x, player.y, player.z);
scene.add(this.rig);
// Public identity riding the server schema, who this peer is (name), the
// three.ws agent they pilot, their verified account wallet, and their
// verified three.ws username (W10, bound server-side from the signed
// presence ticket). These feed the avatar inspector (I / click a
// nameplate), never invented client-side.
this.name = player.name || 'guest';
this.agent = player.agent || '';
this.account = player.account || '';
this.username = player.username || '';
this.onInspect = null; // set by CoinCommunities right after construction
this.label = document.createElement('div');
this.label.className = 'cc-label';
// Two spans instead of bare textContent: a verified player's nameplate
// carries their @handle beside the display name, and both voice (::before)
// and wanted stars (::after) already occupy the label's pseudo-elements.
this._nameEl = document.createElement('span');
this._nameEl.className = 'cc-label-name';
this._nameEl.textContent = player.name || 'guest';
this.label.appendChild(this._nameEl);
this._handleEl = null;
// The nameplate doubles as the peer's click target: labels are cheap,
// always visible, and don't need a skinned-mesh raycast. pointer-events is
// off for .cc-label globally (bubbles must never block the look-drag), so
// re-enable it just for this element.
this.label.style.pointerEvents = 'auto';
this.label.style.cursor = 'pointer';
this.label.title = 'Inspect this player (I)';
this.label.addEventListener('click', (e) => { e.stopPropagation(); this.onInspect?.(); });
this._updateHandleBadge();
document.body.appendChild(this.label);
this.bubble = null;
this._bubbleTimer = null;
this.height = 1.7; // avatar head height; updated once the GLB measures
this.voice = !!player.voice;
this.label.classList.toggle('cc-invoice', this.voice);
// This peer's equipped cosmetic loadout (R23), as the wire string the server
// publishes on the schema. Applied once the GLB measures (setAvatar), and
// re-applied whenever they change their fit (apply()).
this._cosWire = player.cosmetics || '';
this.setAvatar(player.avatar);
// Tag mini-game (R08): red glow ring + 🏃 label for the "it" player.
this.isIt = !!player.it;
if (this.isIt) { this._addGlowRing(); this._addItLabel(); }
// Combat (W07): downed peers lie flat (a lightweight ragdoll, no physics
// sim, just an honest "you're out" pose) and stop being nameplate-clickable
// targets; a wanted peer's nameplate carries their star count.
this.isDead = !!player.dead;
this.heat = player.heat | 0;
if (this.isDead) this._applyDowned(true);
this._updateWantedBadge();
}
setAvatar(url) {
if (url === this._avatarUrl) return;
this._avatarUrl = url;
// rebuild model, clearing the rig takes any worn cosmetics with it, so drop
// the old handle and re-apply once the new GLB has measured.
try { this.cosmetics?.dispose(); } catch {}
this.cosmetics = null;
this._cosApplied = null;
// Deref the shared template + free per-rig materials BEFORE the sweep;
// rig.clear() alone leaks the old model's GPU buffers on every swap.
releaseAvatar(this.rig);
this.rig.clear();
this.anim = new AnimationManager();
// Tag this load so a slower in-flight GLB can't attach to the rig after the
// peer disposed or swapped avatars again, otherwise the resolved model
// lands on a cleared/removed rig (orphaned mesh, or two models at once).
const token = (this._avatarToken = (this._avatarToken || 0) + 1);
const anim = this.anim;
// Locomotion clips only: a room of peers must not each download the whole
// emote library at join. Emotes still lazy-load on first use (playEmoteClip
// fetches a missing clip on demand), and the parsed-clip cache makes the
// idle/walk pair free after the first rig.
resolveAvatarUrl(url).then((u) => buildAvatar(this.rig, u, anim, { clips: 'locomotion' }).then(({ height }) => {
if (this._disposed || token !== this._avatarToken) return;
this.height = height;
anim.crossfadeTo(this.motion === 'walk' || this.motion === 'run' ? CLIP_WALK : CLIP_IDLE, 0);
this.applyCosmetics();
})).catch(() => {});
}
// Dress this peer in their equipped loadout. Idempotent, re-applies only when
// the wire actually changed, and waits for the avatar to measure (setAvatar
// calls it post-load). Reuses the same applyLoadout the local player and the
// creator use, so one wardrobe renders identically everywhere.
applyCosmetics(wire) {
const next = typeof wire === 'string' ? wire : (this._cosWire || '');
this._cosWire = next;
if (this._disposed || !this.height) return;
if (this.cosmetics && this._cosApplied === next) return;
this._cosApplied = next;
try { this.cosmetics?.dispose(); } catch {}
this.cosmetics = applyLoadout(this.rig, this.height, next);
}
_addGlowRing() {
if (this._glowRing) return;
const geo = new RingGeometry(0.5, 0.75, 32);
const mat = new MeshBasicMaterial({ color: 0xff2222, transparent: true, opacity: 0.72, depthWrite: false });
this._glowRing = new Mesh(geo, mat);
this._glowRing.rotation.x = -Math.PI / 2;
this._glowRing.position.y = 0.02;
this.rig.add(this._glowRing);
}
_removeGlowRing() {
if (!this._glowRing) return;
this.rig.remove(this._glowRing);
this._glowRing.geometry.dispose();
this._glowRing.material.dispose();
this._glowRing = null;
}
_addItLabel() {
if (this._itLabel) return;
this._itLabel = document.createElement('div');
this._itLabel.className = 'cc-it-marker';
this._itLabel.textContent = '🏃 IT';
document.body.appendChild(this._itLabel);
}
_removeItLabel() {
if (!this._itLabel) return;
this._itLabel.remove();
this._itLabel = null;
}
_updateItMarker(isIt) {
if (!!isIt === this.isIt) return;
this.isIt = !!isIt;
if (this.isIt) { this._addGlowRing(); this._addItLabel(); }
else { this._removeGlowRing(); this._removeItLabel(); }
}
// Downed pose (W07): tilt the rig onto its side rather than faking a physics
// ragdoll no other part of the client has, an honest, cheap "you're out"
// read that's unmistakable at a glance and costs nothing to reverse.
_applyDowned(down) {
this.rig.rotation.x = down ? -Math.PI / 2 : 0;
this.rig.position.y = down ? 0.15 : this.rig.position.y;
this.rig.traverse((o) => { if (o.material && 'opacity' in o.material) { o.material.transparent = true; o.material.opacity = down ? 0.55 : 1; } });
}
_updateWantedBadge() {
if (this.heat > 0) {
this.label.dataset.wanted = '★'.repeat(Math.min(5, this.heat));
this.label.classList.add('cc-wanted');
} else {
delete this.label.dataset.wanted;
this.label.classList.remove('cc-wanted');
}
}
// Verified three.ws identity on the nameplate (W10): the @handle beside the
// display name marks a signed-in platform account, the signal that clicking
// opens a real profile you can follow and message, not just a guest card.
_updateHandleBadge() {
if (this.username) {
if (!this._handleEl) {
this._handleEl = document.createElement('span');
this._handleEl.className = 'cc-label-handle';
this.label.appendChild(this._handleEl);
}
this._handleEl.textContent = `@${this.username}`;
this.label.classList.add('cc-verified');
this.label.title = `View @${this.username}'s profile (I)`;
} else {
this._handleEl?.remove();
this._handleEl = null;
this.label.classList.remove('cc-verified');
this.label.title = 'Inspect this player (I)';
}
}
apply(player) {
this.targetX = player.x; this.targetY = player.y; this.targetZ = player.z; this.targetYaw = player.yaw;
if (player.name) { this.name = player.name; this._nameEl.textContent = player.name; }
if (player.agent !== undefined) this.agent = player.agent || '';
if (player.account !== undefined) this.account = player.account || '';
if (player.username !== undefined && (player.username || '') !== this.username) {
this.username = player.username || '';
this._updateHandleBadge();
}
if (player.voice !== undefined && !!player.voice !== this.voice) {
this.voice = !!player.voice;
this.label.classList.toggle('cc-invoice', this.voice);
if (!this.voice) this.setSpeaking(false);
}
if (player.avatar !== this._avatarUrl) this.setAvatar(player.avatar);
if (player.cosmetics !== undefined && player.cosmetics !== this._cosWire) this.applyCosmetics(player.cosmetics);
if (player.it !== undefined) this._updateItMarker(player.it);
if (player.dead !== undefined && !!player.dead !== this.isDead) {
this.isDead = !!player.dead;
this._applyDowned(this.isDead);
}
if (player.heat !== undefined && (player.heat | 0) !== this.heat) {
this.heat = player.heat | 0;
this._updateWantedBadge();
}
if (player.motion !== this.motion) {
this.motion = player.motion;
this.anim.crossfadeTo(this.motion === 'walk' || this.motion === 'run' ? CLIP_WALK : CLIP_IDLE, 0.18);
}
if (player.emote && player.emoteTs && player.emoteTs !== this._emoteTs) {
this._emoteTs = player.emoteTs;
playEmoteClip(this.anim, player.emote, this.motion);
}
}
say(text) {
if (this.bubble) this.bubble.remove();
this.bubble = document.createElement('div');
this.bubble.className = 'cc-bubble';
this.bubble.textContent = text;
document.body.appendChild(this.bubble);
clearTimeout(this._bubbleTimer);
this._bubbleTimer = setTimeout(() => { this.bubble?.remove(); this.bubble = null; }, 5000);
}
// Pulse this peer's nameplate while they're talking, so you can see who's
// speaking in a crowd, not just hear them.
setSpeaking(on) {
if (on === this._speaking) return;
this._speaking = on;
this.label.classList.toggle('cc-speaking', on);
}
// `viewer` is the camera position, used only to pick an animation rate. Moving
// and turning stay per-frame for every peer however far away they are: those
// are three lerps, and a peer that slides at 12fps reads as broken from any
// distance. What LOD drops is the expensive half, the skeleton update.
tick(dt, viewer) {
this.rig.position.x += (this.targetX - this.rig.position.x) * REMOTE_LERP;
this.rig.position.y += (this.targetY - this.rig.position.y) * REMOTE_LERP;
this.rig.position.z += (this.targetZ - this.rig.position.z) * REMOTE_LERP;
let d = this.targetYaw - this.curYaw;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
this.curYaw += d * 0.2;
this.rig.rotation.y = this.curYaw;
if (this.anim.currentName === CLIP_WALK) this.anim.setSpeed(this.motion === 'run' ? RUN_TIMESCALE : 1);
// Animation LOD. Posing a skinned avatar costs a bone-matrix pass per peer
// per frame, so a plaza with a live-event crowd in it spends most of its
// frame budget animating people who are specks on the horizon. Peers near
// enough to read keep full-rate animation; the rest are stepped down. The
// elapsed time is accumulated and handed over whole, so a stepped-down peer
// plays its clip at the correct speed, just in coarser increments, and
// crossing a threshold never skips or replays motion.
this._animDue = (this._animDue || 0) + dt;
const dx = this.rig.position.x - viewer.x;
const dz = this.rig.position.z - viewer.z;
const distSq = dx * dx + dz * dz;
const interval = distSq < ANIM_LOD_NEAR_SQ ? 0
: distSq < ANIM_LOD_FAR_SQ ? ANIM_LOD_MID_INTERVAL_S
: ANIM_LOD_FAR_INTERVAL_S;
if (this._animDue >= interval) {
this.anim.update(this._animDue);
this.cosmetics?.tick(this._animDue);
this._animDue = 0;
}
}
dispose() {
this._disposed = true;
try { this.cosmetics?.dispose(); } catch {}
this._removeGlowRing();
this._removeItLabel();
// Free this peer's share of the avatar model before dropping the rig; a
// join→leave churn cycle used to keep every departed peer's geometry and
// textures on the GPU for the rest of the session.
releaseAvatar(this.rig);
this.scene.remove(this.rig);
this.label.remove();
this.bubble?.remove();
clearTimeout(this._bubbleTimer);
}
}
export class CoinCommunities {
constructor(canvas) {
this.canvas = canvas;
this.phase = 'lobby';
this._zen = false;
this.remotes = new Map();
this.keys = new Set();
this.input = new Vector3(); // joystick/keys movement intent (x,z in [-1,1])
this.camYaw = 0.6; this.camPitch = 0.5; this.camDist = 9;
// Spawn within the server's 1.2m max-step radius of its origin (0,0,0) so
// our first authoritative move isn't rejected as a teleport. A small
// random offset keeps players from stacking exactly on each other.
const a = Math.random() * Math.PI * 2, rad = 0.4 + Math.random() * 0.5;
this.localPos = new Vector3(Math.cos(a) * rad, 0, Math.sin(a) * rad);
this.localYaw = Math.PI;
this.motion = 'idle';
this.vy = 0; // vertical velocity for jumps
this.grounded = true; // false while airborne
this._dragging = false; this._lastPtr = null;
this._last = performance.now();
this._lastKick = 0; // R05: timestamp of last ball:kick intent (client-side rate limit)
// Heat control (same system as /club): rAF fires at the display refresh
// rate, so an uncapped loop on a 120/144Hz panel renders 2-2.4x the
// frames of a 60Hz one for no visible gain, that alone makes laptops
// run hot. The governor caps real frame work at 60fps in-world, 30 when
// the window loses focus, 30 under the shared power-saver preference,
// and a near-idle trickle while the opaque lobby fully covers the
// canvas (the arena keeps rendering behind it otherwise, pure waste).
this._governor = createFrameGovernor();
this._focus = trackWindowFocus();
this._powerSaver = getPowerSaver();
// Boot-time quality tier from real capability signals (deviceMemory,
// cores, coarse pointer, same detector /club uses), then a watchdog
// that steps the tier down on sustained slow frames and climbs it
// back (capped at the booted tier) once frames recover, so a single
// load-time hitch doesn't pin the pixel ratio low, and the 3D soft,
// for the whole session. Applied to the renderer in _applyPerfTier.
this._perfTier = detectProfile();
this._watchdog = createFrameWatchdog({
initialTier: this._perfTier,
onDowngrade: (tier) => {
this._perfTier = tier;
this._applyPerfTier();
log.info('[coincommunities] downgrading render tier to', tier);
},
onUpgrade: (tier) => {
this._perfTier = tier;
this._applyPerfTier();
log.info('[coincommunities] recovering render tier to', tier);
},
});
onPowerSaverChange((on) => { this._powerSaver = on; this._applyPerfTier(); });
// W01: real Rapier collision. A single physics world + character controller
// persist for the whole session (Rapier's WASM init is memoized globally);
// district building colliders are rebuilt per coin in enter(). Movement in
// _stepLocal falls back to the legacy direct-mutation path until this
// resolves, so the first frame or two before Rapier's WASM loads still play.
this._physicsOk = false;
this._physics = null;
this._character = null;
this._physicsActivePrev = false;
this._physicsReady = this._initPhysics();
// W01: four-mode chase camera (follow/cinematic/firstperson/topdown),
// shared with /walk via camera-modes.js. 'c' cycles it (see _bindInput).
this._camModes = createCameraModeController({
storageKey: 'play:camera-mode',
onChange: (m) => this.ui?.toast(`Camera: ${CAMERA_MODE_LABELS[m]}`, 'info'),
});
// Embed mode: `?bg=transparent` clears the canvas to alpha 0 and drops the
// graded sky + fog so the world composites onto the host page (e.g. the IBM
// x402 showcase) instead of sitting in its own black box. Default play is
// unaffected.
this._transparentBg = new URLSearchParams(location.search).get('bg') === 'transparent';
// Embed mode: `?biome=<id>` pins every world this session renders to one
// curated look (e.g. `noir` for a dark host surface) instead of the per-coin
// seeded biome, so a /play embed on a partner page stays visually consistent
// with that page. Validated against the biome table; an unknown id is ignored
// and the normal seeded look is used, so default play is unaffected.
this._biomePin = new URLSearchParams(location.search).get('biome') || null;
// True between webglcontextlost and webglcontextrestored (see _bindContextLoss).
this._contextLost = false;
// True while _warmShaders holds the canvas to pre-compile programs.
this._warming = false;
// When the current world became playable, so _loop can give the render-tier
// watchdog a grace period over the tail of world entry. Infinity until then:
// a world that has not opened yet has no frames worth judging.
this._worldSince = Infinity;
this._initRenderer();
this._initScene();
this.ui = new CommunityUI({
onEnter: (coin, tier) => this.enter(coin, { tier }).catch((err) => this._onEnterFailed(err)),
// Holder gate overlay → the scene's gate state machine resolves on each
// action (sign in, link wallet, buy, recheck, cancel).
onHolderAction: (action) => { const r = this._holderGateResolve; this._holderGateResolve = null; r?.(action); },
onLeave: () => this.leave(),
onChat: (t) => this._sendChat(t),
onEmote: (n) => this._emote(n),
onReaction: (emoji) => this.net?.sendReaction(emoji),
onSearch: (q) => this._searchCoins(q),
onRetry: () => this.net?.retry(),
// Resolve the picked value (avatar id, gallery pick, URL) to a loadable,
// host-whitelisted URL before broadcasting, so a mid-session avatar swap
// actually reaches peers (the server rejects bare ids / blob: URLs).
onAvatarChange: (val) => {
if (!this.net || val === GUEST_SENTINEL) return;
resolveAvatarUrl(val)
.then((u) => this.net?.setAvatar(u))
// A pick that fails to resolve keeps the current avatar; peers never
// saw the swap, so there is nothing to roll back.
.catch((err) => log.warn('[coincommunities] avatar swap failed to resolve:', err?.message));
},
onRename: (name) => this._rename(name),
onBuy: () => this._openBuy(),
onShop: () => this._toggleShop(),
onWardrobe: () => this._toggleWardrobe(),
// In-world avatar switcher: change your look without leaving the world.
onAvatarPanel: () => this._toggleAvatarPanel(),
onJobs: () => this._toggleQuests(),
// Friends panel (W09), presence + DMs across every coin world.
onFriends: () => this._toggleFriends(),
// Cold-open intro's zero-friction path, drop straight into the $THREE
// home town with whatever avatar/name is already defaulted, no picking
// required. See play-intro.js and _dropIn() below.
onDropIn: () => this._dropIn(),
// Creator-only (R24): set/clear the token threshold for the Holders world.
onConfigureGate: () => this._configureGate(),
onVoiceToggle: () => this._toggleVoice(),
// Build structures toolbar (R20): pick a composite piece, rotate it, share a
// screenshot of the build, or open this coin's featured builds.
onPickPiece: (id) => this._pickPiece(id),
onRotateBuild: () => this._rotateBuild(),
// Build props (R18): arm/disarm a placeable prop and rotate the armed one.
onPickProp: (id) => this._pickProp(id),
onRotateProp: () => this._rotateProp(),
// P3.3: bring your own prop: validate, upload, arm it for placement.
onUploadProp: (file) => this._uploadProp(file),
// Forge-in-world: generate a brand-new prop from a prompt or photo.
onForgeProp: (req) => this._forgeProp(req),
onShareBuild: () => this._shareBuild(),
onOpenFeatured: () => this._openFeatured(),
onPublishBuild: (meta) => this._publishBuild(meta),
onDance: () => this._triggerDance(),
// Zen mode: strip every overlay for a clean view of the world.
// Photo mode: capture the world (never the chrome) onto a share card.
onPhoto: () => this._openPhotoMode(),
onZen: () => this._setZen(!this._zen),
onFeaturedClosed: () => { this._featuredOpen = false; },
});
// Collaborative building HUD (hotbar + place/break toggle). Hidden until the
// player is in a world and connected, there's nowhere to build otherwise.
this.buildType = 0;
// R20 structures: which composite piece is armed (null = single block) and the
// quarter-turn rotation (0, 3) applied to it. Both drive the ghost preview.
this.buildPiece = null;
this.buildRot = 0;
// R18 props: which placeable prop is armed (null = voxel layer active), its
// quarter-turn rotation, and current scale. When a prop is armed, build clicks
// place free-standing objects through the R01 object channel instead of voxels.
this.buildProp = null;
this.buildPropRot = 0;
this.buildPropScale = 1;
this.buildHud = createBuildHud({
onToggle: (on) => this._onBuildToggle(on),
onPick: (i) => { this.buildType = i; this._refreshGhost(); },
onModeChange: () => this._refreshGhost(),
onClearArea: (scope) => this._onClearArea(scope),
});
// Build permissions (R19), refreshed from the server's build-perms snapshot:
// the player's per-world block cap + usage, and whether they're the coin creator
// (which unlocks the clear-area moderation tool). Solo builds carry no cap.
this._buildPerms = this._defaultBuildPerms();
this.buildHud.root.hidden = true;
this.buildHud.setEnabled(false);
this._hideBootLoader();
this._loadHomeTown();
this._loadCoins();
this._bindInput();
// First-ten-seconds cold open (see play-intro.js header for the audit finding
// this fixes): a first-time visitor otherwise lands on a bare coin grid with
// no context and bounces. Shown once per browser; the reopener in the lobby
// header brings it back any time. NOT shown on a `?coin=<mint>` deep link,
// that visitor already made their choice (a shared world link) and "Drop in
// now" would silently redirect them to the $THREE home town instead of the
// world they clicked into, on top of it already loading behind the modal.
if (!new URLSearchParams(location.search).get('coin')) {
showPlayIntro({ onDropIn: () => this._dropIn() });
}
this._loop = this._loop.bind(this);
requestAnimationFrame(this._loop);
// Wallet-first entry: when the platform has pinned a game token, the sign-in
// gate stands in front of everything, connect a wallet, sign a nonce, and
// hold ≥ the floor before any world opens. The verified wallet becomes the
// account id we carry into every room. When no token is pinned the gate
// resolves instantly (open /play) and nothing below changes. enter() awaits
// this, so a deep link still drops in, just after the gate clears.
this.playPass = '';
this.account = '';
this._playReady = this._ensurePlayAccess();
// Deep link: /play?coin=<mint>&name=&symbol=&image= drops straight into a
// coin's community, so a community is a shareable URL. An optional
// `?avatar=<glb|id>` rides along (used by "See in 3D" links) and is shown
// for this session only, never persisted over the player's saved avatar.
const p = new URLSearchParams(location.search);
this._urlAvatar = (p.get('avatar') || '').trim();
// Capture ?ui= before enter() canonicalises the URL (the share-link
// rewrite drops unknown params); _restoreZen() reads it at world entry,
// where the zen preference actually applies.
this._urlUi = (p.get('ui') || '').trim();
// Captured for the same reason: a player walking back out of a Coin Wars
// battle returns on /play?…&war=<matchKey>, and the war portal echoes that
// result into the world. enter() rewrites the URL before the portal is
// built, so the key has to be read here or it is gone.
this._urlWar = clampParam(p.get('war'), 200);
// Everything past `coin` is decoration a stranger typed into a link they
// shared: it is display-only, never trusted, and clamped to the exact
// lengths the room server clamps to (WalkRoom.onCreate) so what this client
// paints is what every peer will see. Without the clamp a 10 KB `name=`
// tears the HUD apart locally and is silently truncated for everyone else.
const mint = clampParam(p.get('coin'), 64);
if (mint && !isPlausibleMint(mint)) {
// A malformed mint means the link is broken, not that the world is empty.
// Say so and leave them in the lobby, where every real world is one tap
// away, instead of building a convincing world nobody else can join.
// The toast is the signal; this line is telemetry for a designed path,
// so it stays below warn level.
log.info('[coincommunities] ignoring a malformed ?coin= mint:', mint);
this.ui.toast('That world link looks broken, so we left you in the lobby. Pick a community below.', 'warn');
} else if (mint) {
const tier = p.get('tier') === 'holders' ? 'holders' : 'general';
this.enter({
mint,
name: clampParam(p.get('name'), 48),
symbol: clampParam(p.get('symbol'), 16),
// proxiedImageURL drops anything that is not a renderable image
// source (javascript:, data:text/html, an oversized URL), so a hostile
// `image=` resolves to '' and the world takes its generated art path.
image: proxiedImageURL(p.get('image') || '', mint),
}, { tier })
.catch((err) => this._onEnterFailed(err));
}
}
// Both enter() call sites are fire-and-forget, so a throw mid-build would
// otherwise wedge the phase at 'loading' behind a half-built world with no
// feedback. Tear down whatever landed and hand back a working lobby.
_onEnterFailed(err) {
log.error('[coincommunities] enter() failed:', err);
try {
this.leave();
} catch (e) {
// leave() is defensive, but a teardown throw must not mask the lobby reset.
log.warn('[coincommunities] teardown after failed enter():', e?.message);
this.phase = 'lobby';
this.ui?.showLobby?.();
}
this.ui?.toast?.('Could not open that world. Try again.', 'warn');
}
// W01: boot the shared Rapier world once. A flat ground collider covers the
// whole district (buildings are added per-coin in enter(), once the district
// grid is built). Never throws, a WASM failure (unsupported browser, blocked
// worker) degrades to the legacy direct-mutation movement path instead of
// wedging boot.
async _initPhysics() {
try {
this._physics = await PhysicsWorld.create({ gravity: { x: 0, y: -GRAVITY, z: 0 } });
this._physics.addGround(0, DISTRICT.half + 40);
this._physicsOk = true;
} catch (err) {
log.warn('[coincommunities] physics init failed, falling back to legacy movement:', err?.message);
this._physicsOk = false;
}
}
async _hideBootLoader() {
const l = document.getElementById('kx-loading');
if (!l) return;
// Hold the loader until the boot avatar's first frame has rendered so the
// character is actually seen, not flashed away. `ready` always resolves
// (even on WebGL/asset failure) and carries its own 6s safety timeout, so
// this can never wedge the loader open.
const boot = window.__ccBootAvatar;
try { await boot?.ready; } catch { /* proceed regardless */ }
l.classList.add('kx-hidden');
setTimeout(() => { boot?.dispose?.(); l.remove(); }, 600);
}
async _loadCoins(attempt = 0) {
this.ui.setCoinsLoading();
try {
const r = await fetch(TRENDING_URL, { headers: { accept: 'application/json' } });
if (!r.ok) {
// A 429/5xx here is almost always a blip (rate-limit window, deploy churn):
// the feed sits behind a 30s server cache, so one delayed retry usually
// lands. Only after that does the manual-retry error card appear.
if (attempt === 0 && (r.status === 429 || r.status >= 500)) {
const after = Number(r.headers.get('retry-after'));
const delayMs = Number.isFinite(after) && after > 0 ? Math.min(after, 30) * 1000 : 2500;
setTimeout(() => this._loadCoins(1), delayMs);
return;
}
throw new Error('HTTP ' + r.status);
}
const raw = await r.json();
this.ui.setCoins(mapCoins(raw));
} catch (err) {
// Designed failure state: the lobby shows its manual-retry error card,
// so this is expected-path telemetry, not a warning.
log.info('[coincommunities] coin load failed:', err?.message);
this.ui.setCoinsError(() => this._loadCoins());
}
}
// The flagship $THREE town is always pinned to the top of the lobby, even when
// it isn't trending, it's the platform's front door. Show the static identity
// instantly so the card never flashes empty, then refresh name/art/market-cap
// live from pump.fun so the pin is real, not a hardcoded snapshot.
async _loadHomeTown() {
this.ui.setFeatured({ ...HOME_TOWN, official: true });
try {
const r = await fetch(`${COIN_URL}?mint=${HOME_TOWN.mint}`, { headers: { accept: 'application/json' } });
if (!r.ok) throw new Error('HTTP ' + r.status);
const [coin] = mapCoins([await r.json()]);
if (coin?.mint) this.ui.setFeatured({ ...HOME_TOWN, ...coin, official: true });
} catch (err) {
// Non-fatal: the static pin from above stands in until next load.
log.info('[coincommunities] home town refresh failed:', err?.message);
}
}
// The identity behind a bare `?coin=<mint>` link, read from the same pump.fun
// record the lobby cards are built from. Never throws and never stalls entry:
// a miss returns null and the world falls back to its generated art and the
// generic label, exactly as it did before.
async _fetchCoinIdentity(mint) {
// pump.fun's coin lookup is Solana-only, so an EVM world (Robinhood Chain)
// has nothing to gain from the round trip.
if (!SOLANA_MINT_RE.test(String(mint || '').trim())) return null;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), COIN_IDENTITY_TIMEOUT_MS);
try {
const r = await fetch(`${COIN_URL}?mint=${encodeURIComponent(mint)}`, {
headers: { accept: 'application/json' }, signal: ctrl.signal,
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const c = await r.json();
if (!c || (c.mint && c.mint !== mint)) return null;
return {
// Clamped to the same caps the URL params are clamped to: the feed is
// upstream text, and the room server truncates it for every peer.
name: clampParam(c.name, 48),
symbol: clampParam(c.symbol, 16),
image: proxiedImageURL(c.image_uri || c.image || c.imageUri || c.logo || '', mint),
marketCap: c.usd_market_cap || c.market_cap_usd || c.marketCap || 0,
};
} catch (err) {
// A designed miss: our own deadline fired, /api is blocked, or the feed
// blipped. The world takes its generated-art fallback either way, so
// this is expected-path telemetry, not a warning.
const why = err?.name === 'AbortError'
? `timed out after ${COIN_IDENTITY_TIMEOUT_MS}ms`
: (err?.message || String(err));
log.info('[coincommunities] coin identity lookup missed, using generated art:', why);
return null;
} finally {
clearTimeout(timer);
}
}
// Live search across ALL of pump.fun (not just the trending grid) so any
// coin can be turned into a world. Returns mapped coins; throws on failure
// so the UI can distinguish "no matches" from "search unavailable".
async _searchCoins(query) {
const q = (query || '').trim();
if (!q) return [];
const r = await fetch(`${SEARCH_URL}?q=${encodeURIComponent(q)}`, { headers: { accept: 'application/json' } });
if (!r.ok) throw new Error('HTTP ' + r.status);
return mapCoins(await r.json());
}
// ---------------------------------------------------------------- render
_initRenderer() {
let r;
try {
r = new WebGLRenderer({ canvas: this.canvas, antialias: true, alpha: this._transparentBg });
} catch (err) {
// WebGL context creation fails on blocklisted GPUs, machines with
// hardware acceleration disabled, and some in-app/embedded browsers.
// Tag the failure so the boot guard can show a recovery message instead
// of leaving the player on a dead loader, boot-avatar.js already
// degrades gracefully, and the main scene must too.
const e = new Error('WebGL unavailable: ' + (err?.message || err));
e.code = 'NO_WEBGL';
throw e;
}
r.setSize(window.innerWidth, window.innerHeight);
if (this._transparentBg) r.setClearColor(0x000000, 0);
// Cinematic defaults (ACES tone mapping, sRGB output, VSM soft shadows,
// pixel-ratio cap) shared with every other viewer on the platform. Exposure
// is tuned for the dark monochrome arena (the LDR gradient backdrop doesn't
// need the heavy pull the old HDR daylight sky did). _applyPerfTier() below
// still owns the final pixel-ratio/shadow-enabled call so power-saver mode
// and the 'low' tier keep degrading exactly as before.
applyCinematicDefaults(r, { exposure: 1.0, tier: this._perfTier === 'low' ? 'mobile' : this._perfTier });
this.renderer = r;
this._applyPerfTier();
window.addEventListener('resize', () => this._onResize());
this._watchDevicePixelRatio();
this._bindContextLoss();
this._trackSoftKeyboard();
}
// Keep bottom-docked HUD controls above the on-screen keyboard.
//
// The HUD is `position: fixed`, which anchors to the LAYOUT viewport. When a
// phone keyboard opens it shrinks the VISUAL viewport instead, so the layout
// viewport never changes and the chat input the player is typing into sits
// calmly underneath the keyboard, invisible. `vh` units have the same blind
// spot, which is why swapping in dvh does not fix a fixed element.
//
// visualViewport is the only API that reports the covered strip. Publish it as
// --cc-kb and let the bottom-docked rules add it to their offset, so the chat
// (and the emote/reaction rows stacked above it) ride up with the keyboard and
// drop back when it closes. Desktop and any browser without visualViewport keep
// --cc-kb at 0px and are untouched.
_trackSoftKeyboard() {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
this._onKeyboard = () => {
// The strip of layout viewport the keyboard (and any pinch-zoom offset)
// is covering. Never negative: an over-scrolled URL bar can report a
// visual viewport TALLER than the layout one, which would otherwise
// yank the HUD downward off-screen.
const covered = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
// Sub-pixel churn on every scroll frame would thrash layout for no
// visible gain; round and only write when it actually moved.
const px = Math.round(covered);
if (px === this._kbPx) return;
this._kbPx = px;
root.style.setProperty('--cc-kb', `${px}px`);
};
vv.addEventListener('resize', this._onKeyboard);
vv.addEventListener('scroll', this._onKeyboard);
this._onKeyboard();
}
// A browser may take the WebGL context away at any time. On phones it is the