@fonsecabarreto/genesis-gl-core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +19 -2
  2. package/dist/{Camera-DY_8gx3C.d.ts → Camera-CJVYy9fH.d.ts} +13 -2
  3. package/dist/Core/classes/Material.d.ts +1 -1
  4. package/dist/Core/classes/Material.js +1 -1
  5. package/dist/Core/classes/Model.d.ts +3 -3
  6. package/dist/Core/classes/Model.js +1 -1
  7. package/dist/Core/classes/Renderer.d.ts +11 -5
  8. package/dist/Core/classes/Renderer.js +4 -4
  9. package/dist/Core/classes/Scene.d.ts +2 -2
  10. package/dist/Core/classes/Viewport.d.ts +1 -1
  11. package/dist/Core/classes/Viewport.js +1 -1
  12. package/dist/Core/index.d.ts +4 -4
  13. package/dist/Core/index.js +4 -4
  14. package/dist/Core/utils/load-glb.d.ts +3 -3
  15. package/dist/Core/utils/load-glb.js +4 -4
  16. package/dist/Core/utils/parse-obj.d.ts +2 -2
  17. package/dist/Core/utils/parse-obj.js +4 -4
  18. package/dist/Editor/index.d.ts +126 -15
  19. package/dist/Editor/index.js +471 -74
  20. package/dist/Editor/index.js.map +1 -1
  21. package/dist/Game/controls/KeyboardInput.d.ts +4 -4
  22. package/dist/Game/index.d.ts +308 -7
  23. package/dist/Game/index.js +470 -24
  24. package/dist/Game/index.js.map +1 -1
  25. package/dist/{KeyboardInput-DTsfj3tE.d.ts → KeyboardInput-1xOAabI0.d.ts} +95 -14
  26. package/dist/{Material-BGLkldxv.d.ts → Material-DhwSRbP2.d.ts} +8 -0
  27. package/dist/{Model-CQvDXd-b.d.ts → Model-BBZHnUp1.d.ts} +24 -8
  28. package/dist/{chunk-6LS6AO5H.js → chunk-L66K4AZU.js} +36 -30
  29. package/dist/chunk-L66K4AZU.js.map +1 -0
  30. package/dist/{chunk-JK2HEZAT.js → chunk-QOAQVTAB.js} +26 -22
  31. package/dist/chunk-QOAQVTAB.js.map +1 -0
  32. package/dist/{chunk-5TAAXI6S.js → chunk-XMW2MS66.js} +39 -16
  33. package/dist/chunk-XMW2MS66.js.map +1 -0
  34. package/dist/{chunk-QCQVJCSR.js → chunk-ZCJ3MJZD.js} +103 -67
  35. package/dist/chunk-ZCJ3MJZD.js.map +1 -0
  36. package/package.json +1 -1
  37. package/dist/chunk-5TAAXI6S.js.map +0 -1
  38. package/dist/chunk-6LS6AO5H.js.map +0 -1
  39. package/dist/chunk-JK2HEZAT.js.map +0 -1
  40. package/dist/chunk-QCQVJCSR.js.map +0 -1
@@ -231,91 +231,172 @@ function createDefaultScene() {
231
231
  items: []
232
232
  };
233
233
  }
234
+ function createDefaultNamedScene(name = "Scene 1", isActive = false) {
235
+ return { id: crypto.randomUUID(), name, isActive, ...createDefaultScene() };
236
+ }
237
+ function createDefaultMultiSceneFile() {
238
+ return { scenes: [createDefaultNamedScene("Scene 1", true)] };
239
+ }
234
240
 
235
241
  // src/Editor/sections/scene/SceneStore.ts
236
- var STORAGE_KEY2 = "genesisgl__scene";
242
+ var STORAGE_KEY2 = "genesisgl__scenes";
237
243
  var SceneStore = class {
238
- scene = createDefaultScene();
244
+ multiScene = createDefaultMultiSceneFile();
245
+ activeSceneId;
239
246
  listeners = [];
240
- filePath;
241
- constructor(options = {}) {
242
- this.filePath = options.filePath ?? null;
247
+ scenesListeners = [];
248
+ gameSceneListeners = [];
249
+ scenesFilePath;
250
+ constructor(options) {
251
+ this.scenesFilePath = options.scenesFilePath;
252
+ this.activeSceneId = this.multiScene.scenes[0].id;
243
253
  this.loadFromStorage();
244
254
  }
245
255
  // ── Initialisation ──────────────────────────────────────────
246
256
  async init() {
247
- if (!this.filePath) return;
248
257
  try {
249
- const res = await fetch(this.filePath);
258
+ const res = await fetch(this.scenesFilePath);
250
259
  if (!res.ok) {
251
- console.warn(
252
- `[SceneStore] Could not load "${this.filePath}" (${res.status})`
253
- );
260
+ console.warn(`[SceneStore] Could not load "${this.scenesFilePath}" (${res.status})`);
254
261
  return;
255
262
  }
256
263
  const data = await res.json();
257
- this.scene = data;
264
+ this.loadData(data);
258
265
  this.persist();
259
266
  this.emit();
267
+ this.emitScenes();
260
268
  } catch {
261
- console.warn(`[SceneStore] Failed to fetch "${this.filePath}"`);
269
+ console.warn(`[SceneStore] Failed to fetch "${this.scenesFilePath}"`);
270
+ }
271
+ }
272
+ // ── Multi-scene management ───────────────────────────────────
273
+ /** All scenes in the project. */
274
+ getScenes() {
275
+ return this.multiScene.scenes;
276
+ }
277
+ /** Id of the currently active scene. */
278
+ getActiveSceneId() {
279
+ return this.activeSceneId;
280
+ }
281
+ /** Switch which scene is being edited. */
282
+ setActiveScene(id) {
283
+ if (!this.multiScene.scenes.find((s) => s.id === id)) return;
284
+ this.activeSceneId = id;
285
+ this.emit();
286
+ this.emitScenes();
287
+ }
288
+ /** Create a new scene and make it active. Returns the new scene. */
289
+ addScene(name = "New Scene") {
290
+ const scene = createDefaultNamedScene(name);
291
+ this.multiScene.scenes.push(scene);
292
+ this.activeSceneId = scene.id;
293
+ this.persist();
294
+ this.emit();
295
+ this.emitScenes();
296
+ void this.syncToServer();
297
+ return scene;
298
+ }
299
+ /**
300
+ * Delete a scene by id.
301
+ * The last remaining scene is never deleted.
302
+ */
303
+ removeScene(id) {
304
+ if (this.multiScene.scenes.length <= 1) return;
305
+ this.multiScene.scenes = this.multiScene.scenes.filter((s) => s.id !== id);
306
+ if (this.activeSceneId === id) {
307
+ this.activeSceneId = this.multiScene.scenes[0].id;
308
+ }
309
+ this.persist();
310
+ this.emit();
311
+ this.emitScenes();
312
+ void this.syncToServer();
313
+ }
314
+ /** Rename a scene. */
315
+ renameScene(id, name) {
316
+ const scene = this.multiScene.scenes.find((s) => s.id === id);
317
+ if (!scene) return;
318
+ scene.name = name;
319
+ this.persist();
320
+ this.emitScenes();
321
+ void this.syncToServer();
322
+ }
323
+ /**
324
+ * Returns the id of the scene flagged as the **game's** active scene
325
+ * (the one that loads at runtime), or the first scene if none is flagged.
326
+ */
327
+ getActiveGameSceneId() {
328
+ return this.multiScene.scenes.find((s) => s.isActive)?.id ?? this.multiScene.scenes[0].id;
329
+ }
330
+ /**
331
+ * Set exactly one scene as the game's active scene.
332
+ * All other scenes have their `isActive` flag removed.
333
+ */
334
+ setActiveGameScene(id) {
335
+ const target = this.multiScene.scenes.find((s) => s.id === id);
336
+ if (!target) return;
337
+ for (const s of this.multiScene.scenes) {
338
+ s.isActive = s.id === id;
262
339
  }
340
+ this.persist();
341
+ this.emitScenes();
342
+ this.emitGameScene();
343
+ void this.syncToServer();
263
344
  }
264
- // ── Read ─────────────────────────────────────────────────────
345
+ // ── Read (delegated to active scene) ────────────────────────
265
346
  get() {
266
- return this.scene;
347
+ const { id: _id, name: _name, isActive: _ia, ...spec } = this.activeScene;
348
+ return spec;
267
349
  }
268
350
  getItems() {
269
- return this.scene.items;
351
+ return this.activeScene.items;
270
352
  }
271
353
  getItem(id) {
272
- return this.scene.items.find((i) => i.id === id);
354
+ return this.activeScene.items.find((i) => i.id === id);
273
355
  }
274
356
  getPlayerSpawn() {
275
- return [...this.scene.playerSpawn];
357
+ return [...this.activeScene.playerSpawn];
276
358
  }
277
- // ── Mutations ────────────────────────────────────────────────
359
+ // ── Mutations (delegated to active scene) ───────────────────
278
360
  setPlayerSpawn(x, y, z) {
279
- this.scene.playerSpawn = [x, y, z];
361
+ this.activeScene.playerSpawn = [x, y, z];
280
362
  this.persist();
281
363
  this.emit();
282
364
  void this.syncToServer();
283
365
  }
284
366
  addItem(item) {
285
- this.scene.items.push(item);
367
+ this.activeScene.items.push(item);
286
368
  this.persist();
287
369
  this.emit();
288
370
  void this.syncToServer();
289
371
  }
290
372
  updateItem(item) {
291
- const idx = this.scene.items.findIndex((i) => i.id === item.id);
373
+ const idx = this.activeScene.items.findIndex((i) => i.id === item.id);
292
374
  if (idx === -1) return;
293
- this.scene.items[idx] = { ...item };
375
+ this.activeScene.items[idx] = { ...item };
294
376
  this.persist();
295
377
  this.emit();
296
378
  void this.syncToServer();
297
379
  }
298
380
  deleteItem(id) {
299
- this.scene.items = this.scene.items.filter((i) => i.id !== id);
381
+ this.activeScene.items = this.activeScene.items.filter((i) => i.id !== id);
300
382
  this.persist();
301
383
  this.emit();
302
384
  void this.syncToServer();
303
385
  }
304
- /** Replace the entire scene definition. */
386
+ /** Replace the entire active scene definition. */
305
387
  setScene(spec) {
306
- this.scene = spec;
388
+ Object.assign(this.activeScene, spec);
307
389
  this.persist();
308
390
  this.emit();
309
391
  void this.syncToServer();
310
392
  }
311
393
  // ── Server sync ──────────────────────────────────────────────
312
394
  async syncToServer() {
313
- if (!this.filePath) return false;
314
395
  try {
315
- const res = await fetch("/api/scene", {
396
+ const res = await fetch("/api/scenes", {
316
397
  method: "PUT",
317
398
  headers: { "Content-Type": "application/json" },
318
- body: JSON.stringify(this.scene, null, 2)
399
+ body: JSON.stringify(this.multiScene, null, 2)
319
400
  });
320
401
  if (!res.ok) {
321
402
  console.warn(`[SceneStore] Server sync failed (${res.status})`);
@@ -329,7 +410,7 @@ var SceneStore = class {
329
410
  // ── Persistence ──────────────────────────────────────────────
330
411
  persist() {
331
412
  try {
332
- localStorage.setItem(STORAGE_KEY2, JSON.stringify(this.scene));
413
+ localStorage.setItem(STORAGE_KEY2, JSON.stringify(this.multiScene));
333
414
  } catch {
334
415
  console.warn("[SceneStore] Failed to write to localStorage");
335
416
  }
@@ -338,17 +419,23 @@ var SceneStore = class {
338
419
  try {
339
420
  const raw = localStorage.getItem(STORAGE_KEY2);
340
421
  if (!raw) return;
341
- const data = JSON.parse(raw);
342
- if (data && Array.isArray(data.items)) {
343
- this.scene = data;
344
- }
422
+ this.loadData(JSON.parse(raw));
345
423
  } catch {
346
424
  console.warn("[SceneStore] Failed to read from localStorage");
347
425
  }
348
426
  }
427
+ loadData(data) {
428
+ if (data !== null && typeof data === "object" && Array.isArray(data.scenes) && data.scenes.length > 0) {
429
+ this.multiScene = data;
430
+ if (!this.multiScene.scenes.find((s) => s.id === this.activeSceneId)) {
431
+ this.activeSceneId = this.multiScene.scenes[0].id;
432
+ }
433
+ }
434
+ }
349
435
  // ── File I/O ─────────────────────────────────────────────────
350
- async exportToFile(filename = "scene.json") {
351
- const json = JSON.stringify(this.scene, null, 2);
436
+ /** Export the full multi-scene file. */
437
+ async exportToFile(filename = "scenes.json") {
438
+ const json = JSON.stringify(this.multiScene, null, 2);
352
439
  const blob = new Blob([json], { type: "application/json" });
353
440
  if ("showSaveFilePicker" in window) {
354
441
  try {
@@ -372,6 +459,7 @@ var SceneStore = class {
372
459
  a.click();
373
460
  URL.revokeObjectURL(url);
374
461
  }
462
+ /** Import a multi-scene JSON file, replacing current data. */
375
463
  importFromFile() {
376
464
  return new Promise((resolve) => {
377
465
  const input = document.createElement("input");
@@ -386,11 +474,10 @@ var SceneStore = class {
386
474
  try {
387
475
  const text = await file.text();
388
476
  const data = JSON.parse(text);
389
- if (data && Array.isArray(data.items)) {
390
- this.scene = data;
391
- this.persist();
392
- this.emit();
393
- }
477
+ this.loadData(data);
478
+ this.persist();
479
+ this.emit();
480
+ this.emitScenes();
394
481
  } catch {
395
482
  console.error("[SceneStore] Failed to parse imported file");
396
483
  }
@@ -400,16 +487,54 @@ var SceneStore = class {
400
487
  });
401
488
  }
402
489
  // ── Subscriptions ────────────────────────────────────────────
490
+ /** Subscribe to active-scene data changes. */
403
491
  subscribe(fn) {
404
492
  this.listeners.push(fn);
405
493
  return () => {
406
494
  this.listeners = this.listeners.filter((l) => l !== fn);
407
495
  };
408
496
  }
497
+ /**
498
+ * Subscribe to scene-list or active-scene-selection changes.
499
+ * Fired when scenes are added/removed/renamed or the active scene switches.
500
+ */
501
+ subscribeScenes(fn) {
502
+ this.scenesListeners.push(fn);
503
+ return () => {
504
+ this.scenesListeners = this.scenesListeners.filter((l) => l !== fn);
505
+ };
506
+ }
507
+ /**
508
+ * Subscribe to game-scene changes.
509
+ * Fired only when {@link setActiveGameScene} is called — use this to reload
510
+ * the running game world with the newly flagged scene's data.
511
+ */
512
+ subscribeGameScene(fn) {
513
+ this.gameSceneListeners.push(fn);
514
+ return () => {
515
+ this.gameSceneListeners = this.gameSceneListeners.filter((l) => l !== fn);
516
+ };
517
+ }
518
+ // ── Private helpers ──────────────────────────────────────────
519
+ get activeScene() {
520
+ return this.multiScene.scenes.find((s) => s.id === this.activeSceneId) ?? this.multiScene.scenes[0];
521
+ }
409
522
  emit() {
410
- const spec = this.scene;
523
+ const spec = this.get();
411
524
  for (const fn of this.listeners) fn(spec);
412
525
  }
526
+ emitScenes() {
527
+ const scenes = this.getScenes();
528
+ const activeId = this.activeSceneId;
529
+ for (const fn of this.scenesListeners) fn(scenes, activeId);
530
+ }
531
+ emitGameScene() {
532
+ const activeId = this.getActiveGameSceneId();
533
+ const scene = this.multiScene.scenes.find((s) => s.id === activeId);
534
+ if (!scene) return;
535
+ const { id: _id, name: _name, isActive: _ia, ...spec } = scene;
536
+ for (const fn of this.gameSceneListeners) fn(spec);
537
+ }
413
538
  };
414
539
 
415
540
  // src/Editor/sections/scene/SceneSection.ts
@@ -426,6 +551,8 @@ var SceneSection = class _SceneSection {
426
551
  selectedId = null;
427
552
  formEl = null;
428
553
  unsub;
554
+ unsubScenes;
555
+ scenesBar = null;
429
556
  modelRegistry = [];
430
557
  getMaterialNames;
431
558
  modelsFile;
@@ -443,7 +570,10 @@ var SceneSection = class _SceneSection {
443
570
  buildContent: (c) => this.buildContent(c),
444
571
  onActivate: () => this.onActivate(),
445
572
  onDeactivate: () => this.onDeactivate(),
446
- destroy: () => this.unsub?.()
573
+ destroy: () => {
574
+ this.unsub?.();
575
+ this.unsubScenes?.();
576
+ }
447
577
  };
448
578
  }
449
579
  // ── Lifecycle ────────────────────────────────────────────────
@@ -464,6 +594,10 @@ var SceneSection = class _SceneSection {
464
594
  // ── Build ────────────────────────────────────────────────────
465
595
  buildContent(container) {
466
596
  this.root = container;
597
+ this.scenesBar = mk("div", SCENES_BAR_STYLE);
598
+ this.refreshScenesBar();
599
+ container.appendChild(this.scenesBar);
600
+ this.unsubScenes = this.store.subscribeScenes(() => this.refreshScenesBar());
467
601
  const toolbar = mk("div", TOOLBAR_STYLE);
468
602
  toolbar.appendChild(
469
603
  this.mkBtn("\uFF0B Item", () => this.onAdd(), "Add scene item")
@@ -512,6 +646,92 @@ var SceneSection = class _SceneSection {
512
646
  });
513
647
  this.refreshList();
514
648
  }
649
+ // ── Scenes bar ──────────────────────────────────────────────
650
+ refreshScenesBar() {
651
+ if (!this.scenesBar) return;
652
+ this.scenesBar.innerHTML = "";
653
+ const scenes = this.store.getScenes();
654
+ const activeId = this.store.getActiveSceneId();
655
+ for (const scene of scenes) {
656
+ const isEditorActive = scene.id === activeId;
657
+ const isGameActive = !!scene.isActive;
658
+ const tabWrap = mk("div", "display:flex;align-items:stretch;gap:2px;");
659
+ const tab = mk(
660
+ "button",
661
+ SCENE_TAB_STYLE + (isEditorActive ? SCENE_TAB_ACTIVE : "")
662
+ );
663
+ tab.textContent = scene.name;
664
+ tab.title = `Switch to "${scene.name}"`;
665
+ tab.onclick = () => {
666
+ this.selectedId = null;
667
+ this.store.setActiveScene(scene.id);
668
+ this.refreshList();
669
+ this.refreshForm();
670
+ };
671
+ tab.ondblclick = (e) => {
672
+ e.preventDefault();
673
+ const newName = prompt("Rename scene:", scene.name);
674
+ if (newName && newName.trim()) {
675
+ this.store.renameScene(scene.id, newName.trim());
676
+ }
677
+ };
678
+ tabWrap.appendChild(tab);
679
+ const playBtn = mk(
680
+ "button",
681
+ SCENE_PLAY_BTN_STYLE + (isGameActive ? SCENE_PLAY_BTN_ACTIVE : "")
682
+ );
683
+ playBtn.textContent = "\u25B6";
684
+ playBtn.title = isGameActive ? "This scene is loaded by the game (click another to change)" : `Set "${scene.name}" as the game\u2019s startup scene`;
685
+ playBtn.onclick = (e) => {
686
+ e.stopPropagation();
687
+ this.store.setActiveGameScene(scene.id);
688
+ };
689
+ tabWrap.appendChild(playBtn);
690
+ this.scenesBar.appendChild(tabWrap);
691
+ }
692
+ const spacer = mk("div", "flex:1;");
693
+ this.scenesBar.appendChild(spacer);
694
+ const addBtn = mk("button", SCENE_BAR_BTN_STYLE);
695
+ addBtn.textContent = "\uFF0B";
696
+ addBtn.title = "Add new scene";
697
+ addBtn.onclick = () => {
698
+ const name = prompt("New scene name:", `Scene ${scenes.length + 1}`);
699
+ if (name && name.trim()) {
700
+ this.selectedId = null;
701
+ this.store.addScene(name.trim());
702
+ this.refreshList();
703
+ this.refreshForm();
704
+ }
705
+ };
706
+ this.scenesBar.appendChild(addBtn);
707
+ const renameBtn = mk("button", SCENE_BAR_BTN_STYLE);
708
+ renameBtn.textContent = "\u270F\uFE0F";
709
+ renameBtn.title = "Rename active scene";
710
+ renameBtn.onclick = () => {
711
+ const current = scenes.find((s) => s.id === activeId);
712
+ if (!current) return;
713
+ const newName = prompt("Rename scene:", current.name);
714
+ if (newName && newName.trim()) {
715
+ this.store.renameScene(activeId, newName.trim());
716
+ }
717
+ };
718
+ this.scenesBar.appendChild(renameBtn);
719
+ const delBtn = mk("button", SCENE_BAR_BTN_STYLE);
720
+ delBtn.textContent = "\u{1F5D1}";
721
+ delBtn.title = scenes.length <= 1 ? "Cannot delete the last scene" : "Delete active scene";
722
+ delBtn.disabled = scenes.length <= 1;
723
+ delBtn.style.opacity = scenes.length <= 1 ? "0.35" : "1";
724
+ delBtn.onclick = () => {
725
+ const current = scenes.find((s) => s.id === activeId);
726
+ if (!current) return;
727
+ if (!confirm(`Delete scene "${current.name}"?`)) return;
728
+ this.selectedId = null;
729
+ this.store.removeScene(activeId);
730
+ this.refreshList();
731
+ this.refreshForm();
732
+ };
733
+ this.scenesBar.appendChild(delBtn);
734
+ }
515
735
  // ── List ─────────────────────────────────────────────────────
516
736
  refreshList() {
517
737
  if (!this.listEl) return;
@@ -561,12 +781,22 @@ var SceneSection = class _SceneSection {
561
781
  this.refreshList();
562
782
  this.refreshForm();
563
783
  };
564
- const nameEl = mk(
565
- "div",
566
- "font-weight:bold;color:#9cdcfe;margin-bottom:2px;font-size:11px;"
567
- );
568
- nameEl.textContent = `${item.name} [${item.resourceKey}]`;
569
- card.appendChild(nameEl);
784
+ const cardHeader = mk("div", "display:flex;align-items:center;gap:4px;margin-bottom:2px;");
785
+ const typeIcon = mk("span", "font-size:10px;");
786
+ typeIcon.textContent = item.interactive ? "\u26A1" : "\u{1F4E6}";
787
+ const nameEl = mk("div", "font-weight:bold;color:#9cdcfe;font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
788
+ nameEl.textContent = item.name || "(unnamed)";
789
+ cardHeader.appendChild(typeIcon);
790
+ cardHeader.appendChild(nameEl);
791
+ if (item.interactive) {
792
+ const badge = mk("span", "font-size:8px;padding:1px 4px;border-radius:2px;background:#2a3a1a;color:#8c8;border:1px solid #4a7040;flex-shrink:0;");
793
+ badge.textContent = "PHY";
794
+ cardHeader.appendChild(badge);
795
+ }
796
+ card.appendChild(cardHeader);
797
+ if (item.resourceKey) {
798
+ card.appendChild(dataRow("res", item.resourceKey));
799
+ }
570
800
  card.appendChild(
571
801
  dataRow(
572
802
  "pos",
@@ -614,12 +844,27 @@ var SceneSection = class _SceneSection {
614
844
  );
615
845
  }
616
846
  buildItemForm(container, item) {
617
- const title = mk(
847
+ const titleBar = mk(
618
848
  "div",
619
- "font-weight:bold;color:#9cdcfe;padding:0 0 6px;font-size:12px;"
849
+ "display:flex;align-items:center;gap:8px;padding:0 0 10px;border-bottom:1px solid #333;margin-bottom:4px;"
620
850
  );
621
- title.textContent = item.name;
622
- container.appendChild(title);
851
+ const typeIcon = mk("span", "font-size:16px;");
852
+ typeIcon.textContent = item.interactive ? "\u26A1" : "\u{1F4E6}";
853
+ const titleText = mk(
854
+ "div",
855
+ "font-weight:bold;color:#9cdcfe;font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
856
+ );
857
+ titleText.textContent = item.name || "(unnamed)";
858
+ const typeBadge = mk(
859
+ "span",
860
+ "font-size:9px;padding:2px 6px;border-radius:3px;font-family:monospace;flex-shrink:0;" + (item.interactive ? "background:#2a3a1a;color:#8c8;border:1px solid #4a7040;" : "background:#1a1a2a;color:#88a;border:1px solid #445;")
861
+ );
862
+ typeBadge.textContent = item.interactive ? "PHYSICS" : "STATIC";
863
+ titleBar.appendChild(typeIcon);
864
+ titleBar.appendChild(titleText);
865
+ titleBar.appendChild(typeBadge);
866
+ container.appendChild(titleBar);
867
+ container.appendChild(this.sectionHeader("Identity", "\u{1F3F7}"));
623
868
  container.appendChild(
624
869
  this.textRow("Name", item.name, (v) => {
625
870
  this.store.updateItem({ ...item, name: v });
@@ -634,16 +879,7 @@ var SceneSection = class _SceneSection {
634
879
  this.store.updateItem({ ...item, resourceKey: v });
635
880
  })
636
881
  );
637
- const matNames = this.getMaterialNames();
638
- const matOptions = [
639
- { value: "", label: "(default)" },
640
- ...matNames.map((n) => ({ value: n, label: n }))
641
- ];
642
- container.appendChild(
643
- this.selectRow("Material", item.materialKey ?? "", matOptions, (v) => {
644
- this.store.updateItem({ ...item, materialKey: v || null });
645
- })
646
- );
882
+ container.appendChild(this.sectionHeader("Transform", "\u{1F4D0}"));
647
883
  container.appendChild(
648
884
  this.vec3Row(
649
885
  "Position",
@@ -671,21 +907,39 @@ var SceneSection = class _SceneSection {
671
907
  }
672
908
  )
673
909
  );
674
- const hasBounds = item.bounds != null;
910
+ container.appendChild(this.sectionHeader("Rendering", "\u{1F3A8}"));
911
+ const matNames = this.getMaterialNames();
912
+ const matOptions = [
913
+ { value: "", label: "(default)" },
914
+ ...matNames.map((n) => ({ value: n, label: n }))
915
+ ];
675
916
  container.appendChild(
676
- this.checkRow("Custom Bounds", hasBounds, (on) => {
677
- if (on) {
678
- this.store.updateItem({
679
- ...item,
680
- bounds: [0, 0, 0, 1, 1, 1]
681
- });
682
- } else {
683
- this.store.updateItem({ ...item, bounds: null });
684
- }
917
+ this.selectRow("Material", item.materialKey ?? "", matOptions, (v) => {
918
+ this.store.updateItem({ ...item, materialKey: v || null });
919
+ })
920
+ );
921
+ container.appendChild(
922
+ this.checkRow("Collider", item.collider !== false, (on) => {
923
+ this.store.updateItem({ ...item, collider: on });
924
+ })
925
+ );
926
+ container.appendChild(this.sectionHeader("Bounds Override", "\u{1F4CF}"));
927
+ container.appendChild(
928
+ this.checkRow("Custom", item.bounds != null, (on) => {
929
+ this.store.updateItem({
930
+ ...item,
931
+ bounds: on ? [0, 0, 0, 1, 1, 1] : null
932
+ });
685
933
  })
686
934
  );
687
935
  if (item.bounds) {
688
936
  const b = item.bounds;
937
+ const hint = mk(
938
+ "div",
939
+ "color:#555;font-size:9px;margin:-4px 0 6px 76px;font-family:monospace;"
940
+ );
941
+ hint.textContent = "offset (x y z) + size (w h d)";
942
+ container.appendChild(hint);
689
943
  container.appendChild(
690
944
  this.vec3Row("Offset", [b[0], b[1], b[2]], (v) => {
691
945
  this.store.updateItem({
@@ -703,11 +957,56 @@ var SceneSection = class _SceneSection {
703
957
  })
704
958
  );
705
959
  }
960
+ container.appendChild(this.sectionHeader("Physics", "\u26A1"));
706
961
  container.appendChild(
707
- this.checkRow("Collider", item.collider !== false, (on) => {
708
- this.store.updateItem({ ...item, collider: on });
962
+ this.checkRow("Interactive", !!item.interactive, (on) => {
963
+ this.store.updateItem({
964
+ ...item,
965
+ interactive: on,
966
+ radius: on ? item.radius ?? 0.5 : item.radius,
967
+ mass: on ? item.mass ?? 1 : item.mass,
968
+ restitution: on ? item.restitution ?? 0.6 : item.restitution
969
+ });
709
970
  })
710
971
  );
972
+ if (item.interactive) {
973
+ const physHint = mk(
974
+ "div",
975
+ "color:#555;font-size:9px;margin:-4px 0 6px 76px;font-family:monospace;"
976
+ );
977
+ physHint.textContent = "leave resource empty to auto-generate a sphere mesh";
978
+ container.appendChild(physHint);
979
+ container.appendChild(
980
+ this.numberRow(
981
+ "Radius",
982
+ item.radius ?? 0.5,
983
+ { step: 0.05, min: 0.01 },
984
+ (v) => {
985
+ this.store.updateItem({ ...item, radius: v });
986
+ }
987
+ )
988
+ );
989
+ container.appendChild(
990
+ this.numberRow(
991
+ "Mass",
992
+ item.mass ?? 1,
993
+ { step: 0.1, min: 0.01 },
994
+ (v) => {
995
+ this.store.updateItem({ ...item, mass: v });
996
+ }
997
+ )
998
+ );
999
+ container.appendChild(
1000
+ this.numberRow(
1001
+ "Restitution",
1002
+ item.restitution ?? 0.6,
1003
+ { step: 0.05, min: 0, max: 1 },
1004
+ (v) => {
1005
+ this.store.updateItem({ ...item, restitution: v });
1006
+ }
1007
+ )
1008
+ );
1009
+ }
711
1010
  }
712
1011
  // ── CRUD actions ─────────────────────────────────────────────
713
1012
  onAdd() {
@@ -737,6 +1036,37 @@ var SceneSection = class _SceneSection {
737
1036
  this.selectedId = null;
738
1037
  }
739
1038
  // ── Input helpers ────────────────────────────────────────────
1039
+ sectionHeader(label, icon = "") {
1040
+ const wrap = mk("div", SECTION_HEADER_STYLE);
1041
+ if (icon) {
1042
+ const ico = mk("span", "font-size:11px;");
1043
+ ico.textContent = icon;
1044
+ wrap.appendChild(ico);
1045
+ }
1046
+ const lbl = mk(
1047
+ "span",
1048
+ "color:#666;font-size:9px;letter-spacing:1.5px;font-family:monospace;text-transform:uppercase;"
1049
+ );
1050
+ lbl.textContent = label;
1051
+ wrap.appendChild(lbl);
1052
+ return wrap;
1053
+ }
1054
+ numberRow(label, value, opts = {}, onChange) {
1055
+ const row = mk("div", ROW_STYLE);
1056
+ const lbl = mk("div", LABEL_STYLE);
1057
+ lbl.textContent = label;
1058
+ row.appendChild(lbl);
1059
+ const input = document.createElement("input");
1060
+ input.type = "number";
1061
+ input.step = String(opts.step ?? 0.1);
1062
+ if (opts.min !== void 0) input.min = String(opts.min);
1063
+ if (opts.max !== void 0) input.max = String(opts.max);
1064
+ input.value = String(value);
1065
+ input.style.cssText = INPUT_STYLE + ";width:80px;";
1066
+ input.onchange = () => onChange(parseFloat(input.value) || 0);
1067
+ row.appendChild(input);
1068
+ return row;
1069
+ }
740
1070
  vec3Row(label, values, onChange) {
741
1071
  const row = mk("div", ROW_STYLE);
742
1072
  const lbl = mk("div", LABEL_STYLE);
@@ -846,6 +1176,52 @@ var TOOLBAR_STYLE = [
846
1176
  "border-bottom:1px solid #333",
847
1177
  "flex-shrink:0"
848
1178
  ].join(";");
1179
+ var SCENES_BAR_STYLE = [
1180
+ "display:flex",
1181
+ "align-items:center",
1182
+ "gap:4px",
1183
+ "padding:4px 8px",
1184
+ "background:#181818",
1185
+ "border-bottom:1px solid #333",
1186
+ "flex-shrink:0",
1187
+ "overflow-x:auto"
1188
+ ].join(";");
1189
+ var SCENE_TAB_STYLE = [
1190
+ "background:#252525",
1191
+ "color:#aaa",
1192
+ "border:1px solid #444",
1193
+ "border-radius:4px 4px 0 0",
1194
+ "padding:3px 10px",
1195
+ "cursor:pointer",
1196
+ "font-size:10px",
1197
+ "font-family:monospace",
1198
+ "white-space:nowrap"
1199
+ ].join(";");
1200
+ var SCENE_TAB_ACTIVE = ";background:#2a2d3a;color:#9cdcfe;border-color:#5577aa;border-bottom-color:#2a2d3a;";
1201
+ var SCENE_PLAY_BTN_STYLE = [
1202
+ "background:#252525",
1203
+ "color:#555",
1204
+ "border:1px solid #444",
1205
+ "border-radius:0 4px 0 0",
1206
+ "padding:3px 6px",
1207
+ "cursor:pointer",
1208
+ "font-size:9px",
1209
+ "font-family:monospace",
1210
+ "white-space:nowrap",
1211
+ "line-height:1"
1212
+ ].join(";");
1213
+ var SCENE_PLAY_BTN_ACTIVE = ";background:#1a3a1a;color:#4ec94e;border-color:#3a7a3a;";
1214
+ var SCENE_BAR_BTN_STYLE = [
1215
+ "background:transparent",
1216
+ "color:#888",
1217
+ "border:1px solid #444",
1218
+ "border-radius:3px",
1219
+ "padding:2px 6px",
1220
+ "cursor:pointer",
1221
+ "font-size:11px",
1222
+ "font-family:monospace",
1223
+ "flex-shrink:0"
1224
+ ].join(";");
849
1225
  var BTN_STYLE = [
850
1226
  "background:#2a2a2a",
851
1227
  "color:#ccc",
@@ -873,6 +1249,14 @@ var CARD_STYLE = [
873
1249
  "font-family:monospace",
874
1250
  "font-size:10px"
875
1251
  ].join(";");
1252
+ var SECTION_HEADER_STYLE = [
1253
+ "display:flex",
1254
+ "align-items:center",
1255
+ "gap:6px",
1256
+ "margin:14px 0 6px",
1257
+ "padding-bottom:4px",
1258
+ "border-bottom:1px solid #2a2a2a"
1259
+ ].join(";");
876
1260
  var ROW_STYLE = ["display:flex", "gap:6px", "margin-bottom:8px"].join(";");
877
1261
  var LABEL_STYLE = [
878
1262
  "color:#888",
@@ -1533,6 +1917,17 @@ var TopBar = class _TopBar {
1533
1917
  };
1534
1918
  this.root.appendChild(btn);
1535
1919
  }
1920
+ /** Add the Room manager button. */
1921
+ addRoomButton(panel) {
1922
+ const btn = document.createElement("button");
1923
+ btn.textContent = "\u{1F6AA} Rooms";
1924
+ btn.style.cssText = BTN_STYLE3;
1925
+ btn.onclick = () => {
1926
+ panel.toggle();
1927
+ btn.style.cssText = panel.isVisible ? BTN_ACTIVE_STYLE : BTN_STYLE3;
1928
+ };
1929
+ this.root.appendChild(btn);
1930
+ }
1536
1931
  mkToggleBtn(label, panel) {
1537
1932
  return this.mkPanelToggleBtn(label, panel);
1538
1933
  }
@@ -1730,6 +2125,8 @@ export {
1730
2125
  SceneSection,
1731
2126
  SceneStore,
1732
2127
  TopBar,
2128
+ createDefaultMultiSceneFile,
2129
+ createDefaultNamedScene,
1733
2130
  createDefaultScene,
1734
2131
  createDefaultSceneItem,
1735
2132
  createDefaultSpec