@combos-fun/plugin-development-tool 0.0.47 → 0.0.49

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.
@@ -1,8 +1,5 @@
1
1
  import { __decorate } from 'tslib';
2
- import { Component, System, OBSERVER_TYPE, GameObject, decorators } from '@combos-fun/engine';
3
- import { RendererSystem } from '@combos-fun/plugin-renderer';
4
- import { Graphics } from '@combos-fun/plugin-renderer-graphics';
5
- import { Event, HIT_AREA_TYPE } from '@combos-fun/plugin-renderer-event';
2
+ import { Component, System, OBSERVER_TYPE, decorators } from '@combos-fun/engine';
6
3
  import { IDE_PROPERTY_METADATA } from '@combos-fun/inspector-decorator';
7
4
 
8
5
  /** Parent → iframe (also `window` CustomEvent / `game.emit`): toggle pick mode. Payload: `{ enabled: boolean }`. */
@@ -22,10 +19,8 @@ const COMBOS_DEVELOPMENT_TOOL_APPLY_PROPERTY = 'combos-development-tool:apply-pr
22
19
  /** iframe → parent: game scene bootstrap finished; parent may enable pick mode. */
23
20
  const COMBOS_DEVELOPMENT_TOOL_READY = 'combos-development-tool:ready';
24
21
  /**
25
- * Parent → iframe: mute or unmute audio when `SoundSystem` is registered. Payload: `{ muted: boolean }`.
26
- *
27
- * Note: play / pause / resume are owned by the engine core lifecycle protocol
28
- * (`combos-game:set-playing` / `combos-game:state-changed`), not this tool.
22
+ * Parent → iframe: mute or unmute. Handled by `@combos-fun/plugin-sound`.
23
+ * Payload: `{ muted: boolean }`.
29
24
  */
30
25
  const COMBOS_DEVELOPMENT_TOOL_SET_MUTED = 'combos-development-tool:set-muted';
31
26
  /** iframe → parent (and `game.emit`): current mute snapshot after a successful change. */
@@ -45,8 +40,7 @@ const COMBOS_DEVELOPMENT_TOOL_MARKER_OVERLAY_SUCCESS = 'combos-development-tool:
45
40
 
46
41
  /**
47
42
  * Marks a `GameObject` as selectable in editor pick mode. While the development tool is enabled,
48
- * only nodes carrying this component receive a pick `Event`; all other game `Event` components
49
- * are removed and cached until disable.
43
+ * only nodes carrying this component are hit-tested on the canvas overlay.
50
44
  */
51
45
  class CombosDevelopmentToolTarget extends Component {
52
46
  constructor() {
@@ -59,6 +53,69 @@ class CombosDevelopmentToolTarget extends Component {
59
53
  }
60
54
  }
61
55
 
56
+ function cssRgba(color, alpha = 1) {
57
+ const r = (color >> 16) & 0xff;
58
+ const g = (color >> 8) & 0xff;
59
+ const b = color & 0xff;
60
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
61
+ }
62
+ /** Pixi-v8-style path builder backed by `CanvasRenderingContext2D`. */
63
+ class CanvasMarkerGraphics {
64
+ constructor(ctx) {
65
+ this.ctx = ctx;
66
+ }
67
+ clear() {
68
+ this.ctx.beginPath();
69
+ }
70
+ roundRect(x, y, width, height, radius = 0) {
71
+ const ctx = this.ctx;
72
+ if (typeof ctx.roundRect === 'function') {
73
+ ctx.roundRect(x, y, width, height, radius);
74
+ return;
75
+ }
76
+ const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
77
+ ctx.moveTo(x + r, y);
78
+ ctx.lineTo(x + width - r, y);
79
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
80
+ ctx.lineTo(x + width, y + height - r);
81
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
82
+ ctx.lineTo(x + r, y + height);
83
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
84
+ ctx.lineTo(x, y + r);
85
+ ctx.quadraticCurveTo(x, y, x + r, y);
86
+ ctx.closePath();
87
+ }
88
+ rect(x, y, width, height) {
89
+ this.ctx.rect(x, y, width, height);
90
+ }
91
+ circle(x, y, radius) {
92
+ this.ctx.moveTo(x + radius, y);
93
+ this.ctx.arc(x, y, radius, 0, Math.PI * 2);
94
+ }
95
+ poly(points, close = true) {
96
+ if (points.length < 4)
97
+ return;
98
+ this.ctx.moveTo(points[0], points[1]);
99
+ for (let i = 2; i < points.length; i += 2) {
100
+ this.ctx.lineTo(points[i], points[i + 1]);
101
+ }
102
+ if (close) {
103
+ this.ctx.closePath();
104
+ }
105
+ }
106
+ fill(style) {
107
+ this.ctx.fillStyle = cssRgba(style.color, style.alpha ?? 1);
108
+ this.ctx.fill();
109
+ this.ctx.beginPath();
110
+ }
111
+ stroke(style) {
112
+ this.ctx.strokeStyle = cssRgba(style.color, style.alpha ?? 1);
113
+ this.ctx.lineWidth = style.width;
114
+ this.ctx.stroke();
115
+ this.ctx.beginPath();
116
+ }
117
+ }
118
+
62
119
  /**
63
120
  * Registered marker kinds, in priority order: a GameObject is marked by the
64
121
  * first registered component it owns. For now this is **`Sound` only**; the
@@ -68,7 +125,7 @@ class CombosDevelopmentToolTarget extends Component {
68
125
  const MARKER_COMPONENTS = [
69
126
  { componentName: 'Sound', badgeColor: 0x8b5cf6, glyph: 'speaker' },
70
127
  ];
71
- /** Fixed badge size in the owner's local space. */
128
+ /** Fixed badge size in overlay CSS pixels. */
72
129
  const MARKER_ICON_SIZE = 28;
73
130
  /**
74
131
  * First registered marker def whose component is present on `go`, else `null`.
@@ -112,7 +169,7 @@ function drawDotGlyph(g, s) {
112
169
  g.circle(s * 0.5, s * 0.5, s * 0.18);
113
170
  g.fill({ color: GLYPH_COLOR, alpha: 0.96 });
114
171
  }
115
- /** Render a full marker (badge + glyph) into `g`, sized to `size` (local units). */
172
+ /** Render a full marker (badge + glyph) into `g`, sized to `size` (overlay CSS pixels). */
116
173
  function drawMarkerIcon(g, def, size = MARKER_ICON_SIZE) {
117
174
  drawBadge(g, def, size);
118
175
  switch (def.glyph) {
@@ -194,6 +251,85 @@ function mergeAllowedMessageOrigins(extra) {
194
251
  return result;
195
252
  }
196
253
 
254
+ const OVERLAY_Z_INDEX = '2147483000';
255
+ const OVERLAY_ATTR = 'data-combos-development-tool-overlay';
256
+ function ensureParent(gameCanvas, overlay) {
257
+ const parent = gameCanvas.parentElement ?? document.body;
258
+ if (overlay.parentElement !== parent) {
259
+ parent.appendChild(overlay);
260
+ }
261
+ }
262
+ /**
263
+ * Transparent HTML canvas stacked on the game canvas.
264
+ * Pick mode sets `pointer-events: auto` so game Event / Event3D do not receive the tap.
265
+ */
266
+ function createOverlayCanvas() {
267
+ if (typeof document === 'undefined') {
268
+ return null;
269
+ }
270
+ const canvas = document.createElement('canvas');
271
+ canvas.setAttribute(OVERLAY_ATTR, 'true');
272
+ canvas.style.position = 'fixed';
273
+ canvas.style.left = '0';
274
+ canvas.style.top = '0';
275
+ canvas.style.width = '0';
276
+ canvas.style.height = '0';
277
+ canvas.style.margin = '0';
278
+ canvas.style.padding = '0';
279
+ canvas.style.border = '0';
280
+ canvas.style.background = 'transparent';
281
+ canvas.style.pointerEvents = 'none';
282
+ canvas.style.touchAction = 'none';
283
+ canvas.style.userSelect = 'none';
284
+ canvas.style.zIndex = OVERLAY_Z_INDEX;
285
+ canvas.style.display = 'none';
286
+ const ctx = canvas.getContext('2d');
287
+ if (!ctx) {
288
+ return null;
289
+ }
290
+ let pickEnabled = false;
291
+ let visible = false;
292
+ const sync = (gameCanvas) => {
293
+ ensureParent(gameCanvas, canvas);
294
+ const rect = gameCanvas.getBoundingClientRect();
295
+ if (rect.width <= 0 || rect.height <= 0) {
296
+ canvas.style.display = 'none';
297
+ return null;
298
+ }
299
+ canvas.style.display = visible ? 'block' : 'none';
300
+ canvas.style.left = `${rect.left}px`;
301
+ canvas.style.top = `${rect.top}px`;
302
+ canvas.style.width = `${rect.width}px`;
303
+ canvas.style.height = `${rect.height}px`;
304
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
305
+ const pixelW = Math.max(1, Math.round(rect.width * dpr));
306
+ const pixelH = Math.max(1, Math.round(rect.height * dpr));
307
+ if (canvas.width !== pixelW || canvas.height !== pixelH) {
308
+ canvas.width = pixelW;
309
+ canvas.height = pixelH;
310
+ }
311
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
312
+ return { width: rect.width, height: rect.height };
313
+ };
314
+ return {
315
+ canvas,
316
+ ctx,
317
+ sync,
318
+ setPickEnabled(on) {
319
+ pickEnabled = on;
320
+ canvas.style.pointerEvents = pickEnabled ? 'auto' : 'none';
321
+ canvas.style.cursor = pickEnabled ? 'crosshair' : 'default';
322
+ },
323
+ setVisible(on) {
324
+ visible = on;
325
+ canvas.style.display = on ? 'block' : 'none';
326
+ },
327
+ destroy() {
328
+ canvas.remove();
329
+ },
330
+ };
331
+ }
332
+
197
333
  function isSceneSourceAnchor(v) {
198
334
  return (!!v &&
199
335
  typeof v === 'object' &&
@@ -587,29 +723,310 @@ function isClearSelectionMessage(data) {
587
723
  return data.type === COMBOS_DEVELOPMENT_TOOL_CLEAR_SELECTION;
588
724
  }
589
725
 
726
+ /** Screen-space pad used when a target has no positive size / projected extent. */
727
+ const DEFAULT_POINT_EXTENT = 32;
728
+ function identityMat() {
729
+ return { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
730
+ }
731
+ function multiplyMat(p, l) {
732
+ return {
733
+ a: p.a * l.a + p.c * l.b,
734
+ b: p.b * l.a + p.d * l.b,
735
+ c: p.a * l.c + p.c * l.d,
736
+ d: p.b * l.c + p.d * l.d,
737
+ tx: p.a * l.tx + p.c * l.ty + p.tx,
738
+ ty: p.b * l.tx + p.d * l.ty + p.ty,
739
+ };
740
+ }
741
+ function applyMat(m, x, y) {
742
+ return {
743
+ x: m.a * x + m.c * y + m.tx,
744
+ y: m.b * x + m.d * y + m.ty,
745
+ };
746
+ }
747
+ /**
748
+ * Local matrix matching `plugin-renderer` ContainerManager:
749
+ * position (+ parent size * anchor), rotation, scale, pivot = size * origin.
750
+ */
751
+ function localTransformMatrix(t) {
752
+ const x = t.position.x + (t.parent ? t.parent.size.width * t.anchor.x : 0);
753
+ const y = t.position.y + (t.parent ? t.parent.size.height * t.anchor.y : 0);
754
+ const pivotX = t.size.width * t.origin.x;
755
+ const pivotY = t.size.height * t.origin.y;
756
+ const cos = Math.cos(t.rotation);
757
+ const sin = Math.sin(t.rotation);
758
+ const a = cos * t.scale.x;
759
+ const b = sin * t.scale.x;
760
+ const c = -sin * t.scale.y;
761
+ const d = cos * t.scale.y;
762
+ return {
763
+ a,
764
+ b,
765
+ c,
766
+ d,
767
+ tx: x - (pivotX * a + pivotY * c),
768
+ ty: y - (pivotX * b + pivotY * d),
769
+ };
770
+ }
771
+ function worldTransformMatrix(t) {
772
+ const chain = [];
773
+ let cur = t;
774
+ while (cur) {
775
+ chain.push(cur);
776
+ cur = cur.parent;
777
+ }
778
+ chain.reverse();
779
+ let m = identityMat();
780
+ for (const node of chain) {
781
+ m = multiplyMat(m, localTransformMatrix(node));
782
+ }
783
+ return m;
784
+ }
785
+ function aabbFromPoints(points) {
786
+ if (points.length === 0)
787
+ return null;
788
+ let minX = Infinity;
789
+ let minY = Infinity;
790
+ let maxX = -Infinity;
791
+ let maxY = -Infinity;
792
+ for (const p of points) {
793
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y))
794
+ continue;
795
+ minX = Math.min(minX, p.x);
796
+ minY = Math.min(minY, p.y);
797
+ maxX = Math.max(maxX, p.x);
798
+ maxY = Math.max(maxY, p.y);
799
+ }
800
+ if (!Number.isFinite(minX) || !Number.isFinite(minY))
801
+ return null;
802
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
803
+ }
804
+ function designToCss(point, design, css) {
805
+ if (!design || design.width <= 0 || design.height <= 0) {
806
+ return { x: point.x, y: point.y };
807
+ }
808
+ return {
809
+ x: point.x * (css.width / design.width),
810
+ y: point.y * (css.height / design.height),
811
+ };
812
+ }
813
+ function cssToDesign(point, design, css) {
814
+ if (!design || design.width <= 0 || design.height <= 0 || css.width <= 0 || css.height <= 0) {
815
+ return { x: point.x, y: point.y };
816
+ }
817
+ return {
818
+ x: point.x * (design.width / css.width),
819
+ y: point.y * (design.height / css.height),
820
+ };
821
+ }
822
+ function readPositiveNumber(value) {
823
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
824
+ }
825
+ function resolveDesignSize(game) {
826
+ const renderer = game?.getSystem?.('Renderer');
827
+ const screen = renderer?.application?.screen;
828
+ const sw = readPositiveNumber(screen?.width);
829
+ const sh = readPositiveNumber(screen?.height);
830
+ if (sw && sh) {
831
+ return { width: sw, height: sh };
832
+ }
833
+ const pw = readPositiveNumber(renderer?.params?.width);
834
+ const ph = readPositiveNumber(renderer?.params?.height);
835
+ if (pw && ph) {
836
+ return { width: pw, height: ph };
837
+ }
838
+ return null;
839
+ }
840
+ function resolveCamera(game) {
841
+ const renderer = game?.getSystem?.('Renderer3DSystem');
842
+ return renderer?.threeContext?.camera ?? null;
843
+ }
844
+ function resolveObject3D(game, id) {
845
+ const renderer = game?.getSystem?.('Renderer3DSystem');
846
+ return renderer?.threeContext?.nodes?.get?.(id) ?? null;
847
+ }
848
+ function asTransform3D(value) {
849
+ if (!value || typeof value !== 'object')
850
+ return null;
851
+ const t = value;
852
+ const hasWorld = [t.worldPositionX, t.worldPositionY, t.worldPositionZ].some(v => typeof v === 'number' && Number.isFinite(v));
853
+ const hasLocal = [t.positionX, t.positionY, t.positionZ].some(v => typeof v === 'number' && Number.isFinite(v));
854
+ return hasWorld || hasLocal ? t : null;
855
+ }
856
+ function multiplyMat4Vec4(m, x, y, z, w) {
857
+ return [
858
+ m[0] * x + m[4] * y + m[8] * z + m[12] * w,
859
+ m[1] * x + m[5] * y + m[9] * z + m[13] * w,
860
+ m[2] * x + m[6] * y + m[10] * z + m[14] * w,
861
+ m[3] * x + m[7] * y + m[11] * z + m[15] * w,
862
+ ];
863
+ }
864
+ function projectWorldPoint(x, y, z, camera, css) {
865
+ const view = camera.matrixWorldInverse?.elements;
866
+ const proj = camera.projectionMatrix?.elements;
867
+ if (!view || view.length < 16 || !proj || proj.length < 16) {
868
+ return null;
869
+ }
870
+ if (css.width <= 0 || css.height <= 0) {
871
+ return null;
872
+ }
873
+ const viewed = multiplyMat4Vec4(view, x, y, z, 1);
874
+ const clip = multiplyMat4Vec4(proj, viewed[0], viewed[1], viewed[2], viewed[3]);
875
+ if (!Number.isFinite(clip[3]) || Math.abs(clip[3]) < 1e-8) {
876
+ return null;
877
+ }
878
+ if (clip[3] < 0) {
879
+ return null;
880
+ }
881
+ const ndcX = clip[0] / clip[3];
882
+ const ndcY = clip[1] / clip[3];
883
+ const ndcZ = clip[2] / clip[3];
884
+ return {
885
+ x: (ndcX + 1) * 0.5 * css.width,
886
+ y: (1 - ndcY) * 0.5 * css.height,
887
+ depth: ndcZ,
888
+ };
889
+ }
890
+ function transformMat4Point(m, x, y, z) {
891
+ const w = m[3] * x + m[7] * y + m[11] * z + m[15];
892
+ const invW = Math.abs(w) < 1e-8 ? 1 : 1 / w;
893
+ return [
894
+ (m[0] * x + m[4] * y + m[8] * z + m[12]) * invW,
895
+ (m[1] * x + m[5] * y + m[9] * z + m[13]) * invW,
896
+ (m[2] * x + m[6] * y + m[10] * z + m[14]) * invW,
897
+ ];
898
+ }
899
+ function collectObject3DWorldCorners(node) {
900
+ const corners = [];
901
+ const visit = (obj) => {
902
+ const geo = obj.geometry;
903
+ if (!geo)
904
+ return;
905
+ geo.computeBoundingBox?.();
906
+ const box = geo.boundingBox;
907
+ const m = obj.matrixWorld?.elements;
908
+ if (!box || !m || m.length < 16)
909
+ return;
910
+ const { min, max } = box;
911
+ for (const x of [min.x, max.x]) {
912
+ for (const y of [min.y, max.y]) {
913
+ for (const z of [min.z, max.z]) {
914
+ corners.push(transformMat4Point(m, x, y, z));
915
+ }
916
+ }
917
+ }
918
+ };
919
+ node.updateWorldMatrix?.(true, true);
920
+ if (typeof node.traverse === 'function') {
921
+ node.traverse(visit);
922
+ }
923
+ else {
924
+ visit(node);
925
+ }
926
+ return corners;
927
+ }
928
+ function padAround(point, extent) {
929
+ const size = Math.max(1, extent);
930
+ return {
931
+ x: point.x - size / 2,
932
+ y: point.y - size / 2,
933
+ width: size,
934
+ height: size,
935
+ };
936
+ }
937
+ function projectTransform2D(transform, css, design) {
938
+ const matrix = worldTransformMatrix(transform);
939
+ const width = transform.size.width;
940
+ const height = transform.size.height;
941
+ const localCorners = width > 0 && height > 0
942
+ ? [
943
+ { x: 0, y: 0 },
944
+ { x: width, y: 0 },
945
+ { x: width, y: height },
946
+ { x: 0, y: height },
947
+ ]
948
+ : [{ x: 0, y: 0 }];
949
+ const world = localCorners.map(p => applyMat(matrix, p.x, p.y));
950
+ const screen = world.map(p => designToCss(p, design, css));
951
+ const aabb = aabbFromPoints(screen);
952
+ if (aabb && aabb.width > 0 && aabb.height > 0) {
953
+ return aabb;
954
+ }
955
+ const origin = screen[0] ?? { x: 0, y: 0 };
956
+ return padAround(origin, 1);
957
+ }
958
+ function projectTransform3D(transform, camera, css, object3D) {
959
+ if (object3D) {
960
+ const corners = collectObject3DWorldCorners(object3D);
961
+ const projected = corners
962
+ .map(([x, y, z]) => projectWorldPoint(x, y, z, camera, css))
963
+ .filter((p) => p != null);
964
+ const aabb = aabbFromPoints(projected);
965
+ if (aabb && aabb.width > 0 && aabb.height > 0) {
966
+ return aabb;
967
+ }
968
+ }
969
+ const x = transform.worldPositionX ?? transform.positionX ?? 0;
970
+ const y = transform.worldPositionY ?? transform.positionY ?? 0;
971
+ const z = transform.worldPositionZ ?? transform.positionZ ?? 0;
972
+ const projected = projectWorldPoint(x, y, z, camera, css);
973
+ if (!projected)
974
+ return null;
975
+ const scale = Math.max(Math.abs(transform.scaleX ?? 1), Math.abs(transform.scaleY ?? 1), Math.abs(transform.scaleZ ?? 1), 1);
976
+ return padAround(projected, DEFAULT_POINT_EXTENT * scale);
977
+ }
978
+ function projectGameObjectToScreen(go, opts) {
979
+ if (opts.css.width <= 0 || opts.css.height <= 0) {
980
+ return null;
981
+ }
982
+ const t3d = asTransform3D(go.getComponent('Transform3D'));
983
+ if (t3d && opts.camera) {
984
+ return projectTransform3D(t3d, opts.camera, opts.css, opts.object3D);
985
+ }
986
+ return projectTransform2D(go.transform, opts.css, opts.design ?? null);
987
+ }
988
+ function rectContains(rect, x, y) {
989
+ return x >= rect.x && y >= rect.y && x <= rect.x + rect.width && y <= rect.y + rect.height;
990
+ }
991
+ /** Smallest containing rect wins; later items win ties (front-most in walk order). */
992
+ function hitTestRects(x, y, items) {
993
+ let best = null;
994
+ for (let i = 0; i < items.length; i++) {
995
+ const item = items[i];
996
+ if (!rectContains(item.rect, x, y))
997
+ continue;
998
+ const area = Math.max(item.rect.width, 0) * Math.max(item.rect.height, 0);
999
+ if (!best || area < best.area || (area === best.area && i > best.index)) {
1000
+ best = { id: item.id, area, index: i };
1001
+ }
1002
+ }
1003
+ return best ? best.id : null;
1004
+ }
1005
+
590
1006
  const OUTLINE_GO_NAME = '__combosDevelopmentToolOutline';
591
1007
  const MARKER_GO_NAME_PREFIX = '__combosDevelopmentToolMarker:';
1008
+ const OUTLINE_COLOR = '#55ffaa';
592
1009
  /**
593
1010
  * **Off** at start. While enabled:
594
- * - soft-disables game `Event` hit targets (`container.interactive = false`, restored on disable);
595
- * - attaches pick `Event` + outline only on nodes with {@link CombosDevelopmentToolTarget}.
1011
+ * - a transparent HTML canvas overlay captures pointer events (game Event / Event3D
1012
+ * do not receive the tap);
1013
+ * - only nodes with {@link CombosDevelopmentToolTarget} are pickable;
1014
+ * - selection outline and markers are drawn with the Canvas 2D API (no renderer plugin).
596
1015
  *
597
1016
  * Turn on/off via:
598
1017
  * - `window.dispatchEvent(new CustomEvent(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, { detail: { enabled: true } }))`
599
1018
  * - `window.postMessage({ type: COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, enabled: true }, targetOrigin)` (e.g. from parent iframe)
600
1019
  * - `game.emit(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, { enabled: true })` or `getSystem(CombosDevelopmentToolSystem).setEnabled(true)`
601
1020
  *
602
- * Mute (independent of pick mode; parent toolbar or in-game debug UI):
603
- * - `postMessage({ type: COMBOS_DEVELOPMENT_TOOL_SET_MUTED, muted: true })`
604
- * - `game.emit(COMBOS_DEVELOPMENT_TOOL_SET_MUTED, { muted: true })` or `.setMuted(true)`
605
- * Mute applies when `SoundSystem` is registered; otherwise the desired value is remembered until it appears.
606
- * Emits / posts `COMBOS_DEVELOPMENT_TOOL_STATE_CHANGED` with `{ muted }`.
1021
+ * Mute is owned by `@combos-fun/plugin-sound` (`combos-development-tool:set-muted` /
1022
+ * `SoundSystem.setMuted`). This system still forwards `.setMuted()` to
1023
+ * `SoundSystem` so older in-game callers keep working.
607
1024
  *
608
1025
  * Play / pause / resume are handled by the engine core lifecycle protocol
609
1026
  * (`combos-game:set-playing` / `combos-game:state-changed`), not this tool.
610
1027
  *
611
1028
  * While enabled: `game.emit(COMBOS_DEVELOPMENT_TOOL_REFRESH)`, `window` event `combos-development-tool:refresh`,
612
- * or `postMessage({ type: 'combos-development-tool:refresh' })` to re-bind after adding/removing objects.
1029
+ * or `postMessage({ type: 'combos-development-tool:refresh' })` after adding/removing objects.
613
1030
  */
614
1031
  let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends System {
615
1032
  constructor() {
@@ -617,42 +1034,28 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
617
1034
  this.postMessageOrigin = '*';
618
1035
  this.allowedMessageOrigins = mergeAllowedMessageOrigins();
619
1036
  this.enabled = false;
620
- this.outlineGo = null;
621
1037
  this.selected = null;
622
- this.tapHandlers = new Map();
623
- this.tapOwners = new Map();
624
- /** Game `Event` hit targets soft-disabled while enabled (`container.interactive = false`). */
625
- this.disabledGameEventGoIds = new Set();
626
- /** Pick `Event` injected for {@link CombosDevelopmentToolTarget} nodes. */
627
- this.pickEvents = new Set();
1038
+ this.overlay = null;
1039
+ this.overlayCss = { width: 0, height: 0 };
1040
+ this.pointerDownGoId = null;
628
1041
  this.needsRescan = false;
629
- this.lastOutlineBounds = null;
630
1042
  /** Remembered mute when `SoundSystem` is not registered yet. */
631
1043
  this.mutedWithoutSoundSystem = false;
632
1044
  /**
633
1045
  * Marker overlay (see {@link markerLayer}): a visual-only icon layer, toggled
634
- * independently of pick mode + mute. Selection/persistence are NOT handled here
635
- * a marked object (e.g. Sound) carries its own `CombosDevelopmentToolTarget`
636
- * (added by the game code), so it is picked and serialized through the normal
637
- * Target path. The icon just makes the invisible object visible and, by adding
638
- * to the owner's rendered bounds, gives its Target pick a clickable hit area.
1046
+ * independently of pick mode + mute. Selection/persistence still go through
1047
+ * `CombosDevelopmentToolTarget` (added by the Vite plugin).
639
1048
  */
640
1049
  this.markerOverlayEnabled = false;
641
1050
  this.needsMarkerRescan = false;
642
- /** ownerGoId → its marker GO (icon child, Graphics only). */
643
- this.markerGoByOwner = new Map();
1051
+ /** ownerGoId → marker def currently shown. */
1052
+ this.markerOwners = new Set();
644
1053
  this.onWindowSetPickMode = (e) => {
645
1054
  const d = e.detail;
646
1055
  if (d && typeof d.enabled === 'boolean') {
647
1056
  this.setEnabled(d.enabled);
648
1057
  }
649
1058
  };
650
- this.onWindowSetMuted = (e) => {
651
- const d = e.detail;
652
- if (d && typeof d.muted === 'boolean') {
653
- this.setMuted(d.muted);
654
- }
655
- };
656
1059
  this.onWindowSetMarkerOverlay = (e) => {
657
1060
  const d = e.detail;
658
1061
  if (d && typeof d.enabled === 'boolean') {
@@ -674,9 +1077,6 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
674
1077
  if (d.type === COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE && typeof d.enabled === 'boolean') {
675
1078
  this.setEnabled(d.enabled);
676
1079
  }
677
- else if (d.type === COMBOS_DEVELOPMENT_TOOL_SET_MUTED && typeof d.muted === 'boolean') {
678
- this.setMuted(d.muted);
679
- }
680
1080
  else if (d.type === COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY &&
681
1081
  typeof d.enabled === 'boolean') {
682
1082
  this.setMarkerOverlay(d.enabled);
@@ -699,49 +1099,75 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
699
1099
  this.onGameRefresh = () => {
700
1100
  this.requestSceneRescan();
701
1101
  };
702
- this.onGameSetMuted = (payload) => {
703
- if (payload && typeof payload.muted === 'boolean') {
704
- this.setMuted(payload.muted);
705
- }
706
- };
707
1102
  this.onWindowRefresh = () => {
708
1103
  this.requestSceneRescan();
709
1104
  };
1105
+ this.onPointerDown = (e) => {
1106
+ if (!this.enabled)
1107
+ return;
1108
+ e.preventDefault();
1109
+ e.stopPropagation();
1110
+ try {
1111
+ this.overlay?.canvas.setPointerCapture(e.pointerId);
1112
+ }
1113
+ catch {
1114
+ /* ignore */
1115
+ }
1116
+ const hit = this.hitTestPointer(e);
1117
+ this.pointerDownGoId = hit?.id ?? null;
1118
+ };
1119
+ this.onPointerUp = (e) => {
1120
+ if (!this.enabled)
1121
+ return;
1122
+ e.preventDefault();
1123
+ e.stopPropagation();
1124
+ const hit = this.hitTestPointer(e);
1125
+ if (hit && hit.id === this.pointerDownGoId) {
1126
+ this.onSelect(hit, e);
1127
+ }
1128
+ this.pointerDownGoId = null;
1129
+ };
710
1130
  }
711
1131
  static { this.systemName = 'CombosDevelopmentToolSystem'; }
712
1132
  init(params) {
713
1133
  this.postMessageOrigin = params?.postMessageOrigin ?? '*';
714
1134
  this.allowedMessageOrigins = mergeAllowedMessageOrigins(params?.allowedMessageOrigins);
1135
+ this.overlay = createOverlayCanvas();
1136
+ if (this.overlay) {
1137
+ this.overlay.canvas.addEventListener('pointerdown', this.onPointerDown);
1138
+ this.overlay.canvas.addEventListener('pointerup', this.onPointerUp);
1139
+ }
715
1140
  if (typeof window !== 'undefined') {
716
1141
  window.addEventListener(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, this.onWindowSetPickMode);
717
- window.addEventListener(COMBOS_DEVELOPMENT_TOOL_SET_MUTED, this.onWindowSetMuted);
718
1142
  window.addEventListener(COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, this.onWindowSetMarkerOverlay);
719
1143
  window.addEventListener(COMBOS_DEVELOPMENT_TOOL_REFRESH, this.onWindowRefresh);
720
1144
  window.addEventListener('message', this.onWindowMessage);
721
1145
  }
722
1146
  this.game.on(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, this.onGameSetPickMode);
723
- this.game.on(COMBOS_DEVELOPMENT_TOOL_SET_MUTED, this.onGameSetMuted);
724
1147
  this.game.on(COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, this.onGameSetMarkerOverlay);
725
1148
  this.game.on(COMBOS_DEVELOPMENT_TOOL_REFRESH, this.onGameRefresh);
1149
+ this.syncOverlayMode();
726
1150
  if (this.enabled) {
727
1151
  this.needsRescan = true;
728
1152
  }
729
1153
  }
730
1154
  onDestroy() {
1155
+ if (this.overlay) {
1156
+ this.overlay.canvas.removeEventListener('pointerdown', this.onPointerDown);
1157
+ this.overlay.canvas.removeEventListener('pointerup', this.onPointerUp);
1158
+ this.overlay.destroy();
1159
+ this.overlay = null;
1160
+ }
731
1161
  if (typeof window !== 'undefined') {
732
1162
  window.removeEventListener(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, this.onWindowSetPickMode);
733
- window.removeEventListener(COMBOS_DEVELOPMENT_TOOL_SET_MUTED, this.onWindowSetMuted);
734
1163
  window.removeEventListener(COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, this.onWindowSetMarkerOverlay);
735
1164
  window.removeEventListener(COMBOS_DEVELOPMENT_TOOL_REFRESH, this.onWindowRefresh);
736
1165
  window.removeEventListener('message', this.onWindowMessage);
737
1166
  }
738
1167
  this.game.off(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, this.onGameSetPickMode);
739
- this.game.off(COMBOS_DEVELOPMENT_TOOL_SET_MUTED, this.onGameSetMuted);
740
1168
  this.game.off(COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, this.onGameSetMarkerOverlay);
741
1169
  this.game.off(COMBOS_DEVELOPMENT_TOOL_REFRESH, this.onGameRefresh);
742
- this.detachAllPicks();
743
- this.detachAllMarkers();
744
- this.restoreDisabledGameEvents();
1170
+ this.markerOwners.clear();
745
1171
  this.clearSelectionAndNotify('pick-disabled');
746
1172
  }
747
1173
  /** Programmatic toggle (same effect as events). */
@@ -749,14 +1175,14 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
749
1175
  if (this.enabled === on)
750
1176
  return;
751
1177
  this.enabled = on;
1178
+ this.syncOverlayMode();
752
1179
  if (!on) {
753
1180
  this.clearSelectionAndNotify('pick-disabled');
754
- this.detachAllPicks();
755
- this.restoreDisabledGameEvents();
756
1181
  this.postSetSuccess(false);
757
1182
  }
758
1183
  else {
759
1184
  this.needsRescan = true;
1185
+ this.redrawOverlay();
760
1186
  }
761
1187
  }
762
1188
  get isEnabled() {
@@ -765,27 +1191,21 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
765
1191
  /**
766
1192
  * Toggle the marker overlay: pin a persistent icon on every object that owns a
767
1193
  * registered invisible component ({@link markerLayer}, Sound first). Independent
768
- * of pick mode / mute. This layer is purely visual the icon makes the object
769
- * visible and adds to its rendered bounds so the owner's own
770
- * `CombosDevelopmentToolTarget` pick gets a clickable hit area. Selecting and
771
- * persisting edits (e.g. volume) still go through that Target, exactly like any
772
- * other scene edit, so the object must carry a Target (added by the game code).
1194
+ * of pick mode / mute. Selecting and persisting edits still go through the
1195
+ * owner's `CombosDevelopmentToolTarget`.
773
1196
  */
774
1197
  setMarkerOverlay(on) {
775
1198
  if (this.markerOverlayEnabled === on)
776
1199
  return;
777
1200
  this.markerOverlayEnabled = on;
1201
+ this.syncOverlayMode();
778
1202
  if (on) {
779
- // `attachMarkers` requests the pick rescan itself, once the icons exist.
780
1203
  this.needsMarkerRescan = true;
781
1204
  }
782
1205
  else {
783
- this.detachAllMarkers();
1206
+ this.markerOwners.clear();
784
1207
  this.postMarkerOverlaySuccess(false, []);
785
- // Shrink owners' pick hit areas back now that the icons are gone.
786
- if (this.enabled) {
787
- this.needsRescan = true;
788
- }
1208
+ this.redrawOverlay();
789
1209
  }
790
1210
  }
791
1211
  get isMarkerOverlayEnabled() {
@@ -800,64 +1220,42 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
800
1220
  return this.mutedWithoutSoundSystem;
801
1221
  }
802
1222
  /**
803
- * Mute or unmute audio via `SoundSystem` when registered.
804
- * No-op on the audio graph if `SoundSystem` is missing; value is remembered for `isMuted`.
1223
+ * @deprecated Host mute is handled by `@combos-fun/plugin-sound`.
1224
+ * Forwards to `SoundSystem.muted` when that system is registered.
805
1225
  */
806
1226
  setMuted(muted) {
807
1227
  const sound = this.getSoundSystem();
808
1228
  if (sound) {
809
1229
  sound.muted = muted;
1230
+ return;
810
1231
  }
811
- else {
812
- this.mutedWithoutSoundSystem = muted;
813
- }
814
- this.postStateChanged();
1232
+ this.mutedWithoutSoundSystem = muted;
815
1233
  }
816
- update(e) {
1234
+ update() {
817
1235
  if (this.enabled && this.needsRescan) {
818
1236
  this.needsRescan = false;
819
- this.attachEditMode();
820
1237
  this.postSetSuccess(true);
821
1238
  }
822
- // Attaching markers is deferred to run *after* the pick rescan above so that a
823
- // newly drawn icon's rebuild lands on a later frame (see `attachMarkers`): the
824
- // icon child needs a rendered container before it contributes to the owner's
825
- // bounds, and only then does the owner's Target pick get a hit area over it.
826
1239
  if (this.markerOverlayEnabled && this.needsMarkerRescan) {
827
1240
  this.needsMarkerRescan = false;
828
1241
  this.attachMarkers();
829
1242
  }
830
- for (const go of [...this.tapOwners.values()]) {
831
- if (go.destroyed) {
832
- this.releasePick(go);
833
- }
834
- }
835
- // Drop markers whose owner disappeared while the overlay is on.
836
- for (const [ownerId, marker] of [...this.markerGoByOwner.entries()]) {
837
- const owner = this.findGameObjectById(ownerId);
838
- if (!owner || owner.destroyed || marker.destroyed) {
839
- this.removeMarker(ownerId);
840
- }
1243
+ if (this.selected?.destroyed) {
1244
+ this.clearSelectionAndNotify('target-removed');
841
1245
  }
842
- if (!this.enabled || !this.selected || !this.outlineGo)
843
- return;
844
- this.updateSelectionOutline();
1246
+ }
1247
+ lateUpdate(_e) {
1248
+ this.redrawOverlay();
845
1249
  }
846
1250
  componentChanged(changed) {
847
- if (!this.enabled)
848
- return;
849
1251
  const { type, gameObject, componentName } = changed;
850
1252
  if (!gameObject || componentName !== 'CombosDevelopmentToolTarget')
851
1253
  return;
852
- if (type === OBSERVER_TYPE.ADD) {
853
- this.stripGameEvent(gameObject);
854
- this.ensurePick(gameObject);
1254
+ if (type === OBSERVER_TYPE.REMOVE && this.selected === gameObject) {
1255
+ this.clearSelectionAndNotify('target-removed');
855
1256
  }
856
- else if (type === OBSERVER_TYPE.REMOVE) {
857
- this.releasePick(gameObject);
858
- if (this.selected === gameObject) {
859
- this.clearSelectionAndNotify('target-removed');
860
- }
1257
+ if (this.markerOverlayEnabled) {
1258
+ this.needsMarkerRescan = true;
861
1259
  }
862
1260
  }
863
1261
  /** Re-bind pick targets / markers after dynamic scene changes while active. */
@@ -869,24 +1267,10 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
869
1267
  this.needsMarkerRescan = true;
870
1268
  }
871
1269
  }
872
- attachEditMode() {
873
- this.detachAllPicks();
874
- const list = [];
875
- for (const tr of this.game.scene.transform.children) {
876
- this.collectGameObjects(tr.gameObject, list);
877
- }
878
- for (const go of list) {
879
- if (this.isIgnoredPickGo(go))
880
- continue;
881
- this.stripGameEvent(go);
882
- }
883
- for (const go of list) {
884
- if (this.isIgnoredPickGo(go))
885
- continue;
886
- if (!go.getComponent(CombosDevelopmentToolTarget))
887
- continue;
888
- this.ensurePick(go);
889
- }
1270
+ syncOverlayMode() {
1271
+ const active = this.enabled || this.markerOverlayEnabled;
1272
+ this.overlay?.setVisible(active);
1273
+ this.overlay?.setPickEnabled(this.enabled);
890
1274
  }
891
1275
  collectGameObjects(go, out) {
892
1276
  out.push(go);
@@ -894,203 +1278,134 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
894
1278
  this.collectGameObjects(tr.gameObject, out);
895
1279
  }
896
1280
  }
1281
+ listSceneObjects() {
1282
+ const list = [];
1283
+ const scene = this.game.scene;
1284
+ if (!scene?.transform?.children)
1285
+ return list;
1286
+ for (const tr of scene.transform.children) {
1287
+ this.collectGameObjects(tr.gameObject, list);
1288
+ }
1289
+ return list;
1290
+ }
897
1291
  isIgnoredPickGo(go) {
898
- return go.name === OUTLINE_GO_NAME || go === this.outlineGo || this.isMarkerGo(go);
1292
+ return go.name === OUTLINE_GO_NAME || this.isMarkerGo(go);
899
1293
  }
900
1294
  isMarkerGo(go) {
901
1295
  return typeof go.name === 'string' && go.name.startsWith(MARKER_GO_NAME_PREFIX);
902
1296
  }
903
- stripGameEvent(go) {
904
- if (this.disabledGameEventGoIds.has(go.id))
905
- return;
906
- const ev = go.getComponent(Event);
907
- if (!ev)
908
- return;
909
- const container = this.getRendererContainer(go);
910
- if (!container)
911
- return;
912
- try {
913
- container.interactive = false;
914
- this.disabledGameEventGoIds.add(go.id);
915
- }
916
- catch {
917
- /* ignore */
918
- }
1297
+ projectGo(go) {
1298
+ return projectGameObjectToScreen(go, {
1299
+ css: this.overlayCss,
1300
+ design: resolveDesignSize(this.game),
1301
+ camera: resolveCamera(this.game),
1302
+ object3D: resolveObject3D(this.game, go.id),
1303
+ });
919
1304
  }
920
- restoreDisabledGameEvents() {
921
- for (const id of [...this.disabledGameEventGoIds]) {
922
- const go = this.findGameObjectById(id);
923
- if (!go || go.destroyed) {
924
- this.disabledGameEventGoIds.delete(id);
1305
+ collectOverlayHits() {
1306
+ const hits = [];
1307
+ for (const go of this.listSceneObjects()) {
1308
+ if (go.destroyed || this.isIgnoredPickGo(go))
925
1309
  continue;
1310
+ if (!go.getComponent(CombosDevelopmentToolTarget))
1311
+ continue;
1312
+ const rect = this.projectGo(go);
1313
+ if (rect) {
1314
+ hits.push({ go, rect });
926
1315
  }
927
- const container = this.getRendererContainer(go);
928
- if (container) {
929
- try {
930
- container.interactive = true;
931
- }
932
- catch {
933
- /* ignore */
1316
+ if (this.markerOverlayEnabled && resolveMarkerDef(go)) {
1317
+ const markerRect = this.markerRectFor(go, rect);
1318
+ if (markerRect) {
1319
+ hits.push({ go, rect: markerRect });
934
1320
  }
935
1321
  }
936
- this.disabledGameEventGoIds.delete(id);
937
- }
938
- }
939
- detachAllPicks() {
940
- for (const go of [...this.tapOwners.values()]) {
941
- this.releasePick(go);
942
1322
  }
1323
+ return hits;
943
1324
  }
944
- ensurePick(go) {
945
- if (this.tapHandlers.has(go.id))
946
- return;
947
- const container = this.getRendererContainer(go);
948
- if (container) {
949
- try {
950
- container.interactive = true;
951
- }
952
- catch {
953
- /* ignore */
954
- }
955
- }
956
- let ev = go.getComponent(Event);
957
- if (!ev) {
958
- ev = go.addComponent(this.createPickEvent(go));
959
- this.pickEvents.add(go.id);
960
- }
961
- const handler = payload => this.onSelect(go, payload);
962
- this.tapHandlers.set(go.id, handler);
963
- this.tapOwners.set(go.id, go);
964
- ev.on('tap', handler);
965
- }
966
- createPickEvent(go) {
967
- const bounds = this.resolvePickBounds(go);
968
- if (bounds.width > 0 && bounds.height > 0) {
969
- return new Event({
970
- hitArea: {
971
- type: HIT_AREA_TYPE.Rect,
972
- style: {
973
- x: bounds.x,
974
- y: bounds.y,
975
- width: bounds.width,
976
- height: bounds.height,
977
- },
978
- },
979
- });
980
- }
981
- return new Event();
982
- }
983
- resolvePickBounds(go) {
984
- if (this.shouldPreferRenderedBounds(go)) {
985
- const fromOwnGraphics = this.resolveOwnGraphicsLocalBounds(go);
986
- if (fromOwnGraphics) {
987
- return fromOwnGraphics;
988
- }
989
- const fromGraphics = this.resolveContainerLocalBounds(go);
990
- if (fromGraphics) {
991
- return fromGraphics;
992
- }
993
- }
994
- const { width, height } = go.transform.size;
995
- if (width > 0 && height > 0) {
996
- return { x: 0, y: 0, width, height };
997
- }
998
- const fromContainer = this.resolveContainerLocalBounds(go);
999
- if (fromContainer) {
1000
- return fromContainer;
1001
- }
1002
- return { x: 0, y: 0, width: 1, height: 1 };
1003
- }
1004
- shouldPreferRenderedBounds(go) {
1005
- return go.getComponent(Graphics) != null;
1006
- }
1007
- /** Bounds of this GO's own Graphics only — excludes child GOs such as the selection outline. */
1008
- resolveOwnGraphicsLocalBounds(go) {
1009
- const gfx = go.getComponent(Graphics);
1010
- if (!gfx?.graphics)
1325
+ markerRectFor(go, objectRect) {
1326
+ const rect = objectRect ?? this.projectGo(go);
1327
+ if (!rect)
1011
1328
  return null;
1012
- try {
1013
- const bounds = gfx.graphics.getLocalBounds();
1014
- if (bounds.width > 0 && bounds.height > 0) {
1015
- return {
1016
- x: bounds.x,
1017
- y: bounds.y,
1018
- width: bounds.width,
1019
- height: bounds.height,
1020
- };
1021
- }
1329
+ if (go.getComponent('Transform3D')) {
1330
+ return {
1331
+ x: rect.x + rect.width / 2 - MARKER_ICON_SIZE / 2,
1332
+ y: rect.y + rect.height / 2 - MARKER_ICON_SIZE / 2,
1333
+ width: MARKER_ICON_SIZE,
1334
+ height: MARKER_ICON_SIZE,
1335
+ };
1022
1336
  }
1023
- catch {
1024
- /* ignore */
1025
- }
1026
- return null;
1027
- }
1028
- resolveContainerLocalBounds(go) {
1029
- const container = this.getRendererContainer(go);
1030
- if (!container)
1031
- return null;
1032
- const outlineContainer = this.getOutlineContainerIfChildOf(go);
1033
- let outlineDetached = false;
1034
- if (outlineContainer?.parent === container) {
1035
- container.removeChild(outlineContainer);
1036
- outlineDetached = true;
1037
- }
1038
- try {
1039
- const bounds = container.getLocalBounds();
1040
- if (bounds.width > 0 && bounds.height > 0) {
1041
- return {
1042
- x: bounds.x,
1043
- y: bounds.y,
1044
- width: bounds.width,
1045
- height: bounds.height,
1046
- };
1047
- }
1048
- }
1049
- catch {
1050
- /* ignore */
1051
- }
1052
- finally {
1053
- if (outlineDetached && outlineContainer) {
1054
- container.addChild(outlineContainer);
1055
- }
1056
- }
1057
- return null;
1337
+ return {
1338
+ x: rect.x,
1339
+ y: rect.y,
1340
+ width: MARKER_ICON_SIZE,
1341
+ height: MARKER_ICON_SIZE,
1342
+ };
1058
1343
  }
1059
- /** Outline is parented under the selected GO; exclude it when measuring content bounds. */
1060
- getOutlineContainerIfChildOf(go) {
1061
- if (!this.outlineGo || this.outlineGo.parent !== go) {
1062
- return null;
1344
+ overlayPoint(e) {
1345
+ const canvas = this.overlay?.canvas;
1346
+ if (!canvas)
1347
+ return { x: 0, y: 0 };
1348
+ const rect = canvas.getBoundingClientRect();
1349
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
1350
+ }
1351
+ hitTestPointer(e) {
1352
+ const pt = this.overlayPoint(e);
1353
+ const hits = this.collectOverlayHits();
1354
+ return hitTestRects(pt.x, pt.y, hits.map(h => ({ id: h.go, rect: h.rect })));
1355
+ }
1356
+ redrawOverlay() {
1357
+ const overlay = this.overlay;
1358
+ if (!overlay)
1359
+ return;
1360
+ const active = this.enabled || this.markerOverlayEnabled;
1361
+ if (!active) {
1362
+ overlay.setVisible(false);
1363
+ return;
1063
1364
  }
1064
- return this.getRendererContainer(this.outlineGo);
1365
+ const gameCanvas = this.game.canvas ?? this.game.scene?.canvas;
1366
+ if (!gameCanvas)
1367
+ return;
1368
+ const css = overlay.sync(gameCanvas);
1369
+ if (!css)
1370
+ return;
1371
+ this.overlayCss = css;
1372
+ overlay.ctx.clearRect(0, 0, css.width, css.height);
1373
+ this.drawMarkers(overlay.ctx);
1374
+ this.drawSelectionOutline(overlay.ctx);
1065
1375
  }
1066
- getRendererContainer(go) {
1067
- const rendererSystem = this.game.getSystem(RendererSystem);
1068
- if (!rendererSystem?.containerManager)
1069
- return null;
1070
- try {
1071
- return rendererSystem.containerManager.getContainer(go.id);
1072
- }
1073
- catch {
1074
- return null;
1376
+ drawMarkers(ctx) {
1377
+ if (!this.markerOverlayEnabled)
1378
+ return;
1379
+ const gfx = new CanvasMarkerGraphics(ctx);
1380
+ for (const go of this.listSceneObjects()) {
1381
+ if (go.destroyed || this.isIgnoredPickGo(go))
1382
+ continue;
1383
+ const def = resolveMarkerDef(go);
1384
+ if (!def)
1385
+ continue;
1386
+ const objectRect = this.projectGo(go);
1387
+ const rect = this.markerRectFor(go, objectRect);
1388
+ if (!rect)
1389
+ continue;
1390
+ ctx.save();
1391
+ ctx.translate(rect.x, rect.y);
1392
+ drawMarkerIcon(gfx, def, MARKER_ICON_SIZE);
1393
+ ctx.restore();
1075
1394
  }
1076
1395
  }
1077
- releasePick(go) {
1078
- const handler = this.tapHandlers.get(go.id);
1079
- if (!handler)
1396
+ drawSelectionOutline(ctx) {
1397
+ if (!this.enabled || !this.selected || this.selected.destroyed)
1080
1398
  return;
1081
- const ev = go.getComponent(Event);
1082
- ev?.off('tap', handler);
1083
- this.tapHandlers.delete(go.id);
1084
- this.tapOwners.delete(go.id);
1085
- if (this.pickEvents.has(go.id)) {
1086
- try {
1087
- go.removeComponent(Event);
1088
- }
1089
- catch {
1090
- /* ignore */
1091
- }
1092
- this.pickEvents.delete(go.id);
1093
- }
1399
+ const rect = this.projectGo(this.selected);
1400
+ if (!rect)
1401
+ return;
1402
+ const design = resolveDesignSize(this.game);
1403
+ const scale = design ? this.overlayCss.width / design.width : 1;
1404
+ ctx.save();
1405
+ ctx.strokeStyle = OUTLINE_COLOR;
1406
+ ctx.lineWidth = Math.max(2, 4 * (Number.isFinite(scale) && scale > 0 ? scale : 1));
1407
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
1408
+ ctx.restore();
1094
1409
  }
1095
1410
  postSetSuccess(enabled) {
1096
1411
  const payload = {
@@ -1102,34 +1417,16 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1102
1417
  }
1103
1418
  this.game.emit(COMBOS_DEVELOPMENT_TOOL_PICK_MODE_SUCCESS, { enabled });
1104
1419
  }
1105
- postStateChanged() {
1106
- const state = {
1107
- muted: this.isMuted,
1108
- };
1109
- const payload = {
1110
- type: COMBOS_DEVELOPMENT_TOOL_STATE_CHANGED,
1111
- ...state,
1112
- };
1113
- if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
1114
- window.parent.postMessage(payload, this.postMessageOrigin);
1115
- }
1116
- this.game.emit(COMBOS_DEVELOPMENT_TOOL_STATE_CHANGED, state);
1117
- }
1118
- /** (Re)build markers for every object owning a registered marker component. */
1119
1420
  attachMarkers() {
1120
- this.detachAllMarkers();
1121
- const list = [];
1122
- for (const tr of this.game.scene.transform.children) {
1123
- this.collectGameObjects(tr.gameObject, list);
1124
- }
1421
+ this.markerOwners.clear();
1125
1422
  const counts = new Map();
1126
- for (const go of list) {
1423
+ for (const go of this.listSceneObjects()) {
1127
1424
  if (this.isIgnoredPickGo(go))
1128
1425
  continue;
1129
1426
  const def = resolveMarkerDef(go);
1130
1427
  if (!def)
1131
1428
  continue;
1132
- this.createMarker(go, def);
1429
+ this.markerOwners.add(go.id);
1133
1430
  counts.set(def.componentName, (counts.get(def.componentName) ?? 0) + 1);
1134
1431
  }
1135
1432
  const markers = [...counts.entries()].map(([componentName, count]) => ({
@@ -1137,43 +1434,7 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1137
1434
  count,
1138
1435
  }));
1139
1436
  this.postMarkerOverlaySuccess(true, markers);
1140
- // Rebuild picks next frame: the icons just added need a rendered container
1141
- // before they enlarge their owners' bounds into a clickable hit area. Runs on
1142
- // a later frame because the pick rescan block already executed above.
1143
- if (this.enabled) {
1144
- this.needsRescan = true;
1145
- }
1146
- }
1147
- createMarker(owner, def) {
1148
- if (this.markerGoByOwner.has(owner.id))
1149
- return;
1150
- const bounds = this.resolvePickBounds(owner);
1151
- const marker = new GameObject(MARKER_GO_NAME_PREFIX + owner.id, {
1152
- size: { width: MARKER_ICON_SIZE, height: MARKER_ICON_SIZE },
1153
- position: { x: bounds.x, y: bounds.y },
1154
- origin: { x: 0, y: 0 },
1155
- });
1156
- const gfx = marker.addComponent(new Graphics());
1157
- if (gfx?.graphics) {
1158
- drawMarkerIcon(gfx.graphics, def, MARKER_ICON_SIZE);
1159
- }
1160
- owner.addChild(marker);
1161
- this.markerGoByOwner.set(owner.id, marker);
1162
- }
1163
- removeMarker(ownerId) {
1164
- const marker = this.markerGoByOwner.get(ownerId);
1165
- if (!marker)
1166
- return;
1167
- this.markerGoByOwner.delete(ownerId);
1168
- if (!marker.destroyed) {
1169
- marker.destroy();
1170
- }
1171
- }
1172
- detachAllMarkers() {
1173
- for (const ownerId of [...this.markerGoByOwner.keys()]) {
1174
- this.removeMarker(ownerId);
1175
- }
1176
- this.markerGoByOwner.clear();
1437
+ this.redrawOverlay();
1177
1438
  }
1178
1439
  postMarkerOverlaySuccess(enabled, markers) {
1179
1440
  const total = markers.reduce((sum, m) => sum + m.count, 0);
@@ -1197,59 +1458,33 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1197
1458
  }
1198
1459
  return system;
1199
1460
  }
1200
- ensureOutline() {
1201
- if (this.outlineGo)
1202
- return;
1203
- const go = new GameObject(OUTLINE_GO_NAME, {
1204
- size: { width: 1, height: 1 },
1205
- position: { x: 0, y: 0 },
1206
- origin: { x: 0, y: 0 },
1207
- });
1208
- go.addComponent(new Graphics());
1209
- this.outlineGo = go;
1210
- }
1211
- onSelect(go, tap) {
1461
+ onSelect(go, e) {
1212
1462
  if (!this.enabled)
1213
1463
  return;
1214
- tap?.stopPropagation?.();
1215
- this.ensureOutline();
1216
- if (!this.outlineGo)
1217
- return;
1218
1464
  this.selected = go;
1219
- if (this.outlineGo.parent) {
1220
- this.outlineGo.remove();
1221
- }
1222
- go.addChild(this.outlineGo);
1223
- this.invalidateOutlineBounds();
1224
- this.updateSelectionOutline();
1465
+ this.redrawOverlay();
1466
+ const overlayPt = this.overlayPoint(e);
1467
+ const pointer = cssToDesign(overlayPt, resolveDesignSize(this.game), this.overlayCss);
1225
1468
  const snapshot = buildGameObjectSnapshot(go);
1226
1469
  const payload = {
1227
1470
  type: COMBOS_DEVELOPMENT_TOOL_GAMEOBJECT_SELECTED,
1228
1471
  snapshot,
1229
- pointer: tap?.data?.position ?? null,
1472
+ pointer,
1230
1473
  };
1231
1474
  if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
1232
1475
  window.parent.postMessage(payload, this.postMessageOrigin);
1233
1476
  }
1234
1477
  }
1235
1478
  findGameObjectById(id) {
1236
- const stack = [];
1237
- for (const tr of this.game.scene.transform.children) {
1238
- this.collectGameObjects(tr.gameObject, stack);
1239
- }
1240
- return stack.find(go => go.id === id) ?? null;
1479
+ return this.listSceneObjects().find(go => go.id === id) ?? null;
1241
1480
  }
1242
1481
  findGameObjectBySource(source) {
1243
- const stack = [];
1244
- for (const tr of this.game.scene.transform.children) {
1245
- this.collectGameObjects(tr.gameObject, stack);
1246
- }
1247
1482
  const wantFile = source.file.trim();
1248
1483
  const wantAnchor = (source.anchor ?? '').trim();
1249
1484
  if (!wantAnchor) {
1250
1485
  return null;
1251
1486
  }
1252
- for (const go of stack) {
1487
+ for (const go of this.listSceneObjects()) {
1253
1488
  const anchor = readSourceAnchor(go);
1254
1489
  if (!anchor || anchor.file !== wantFile) {
1255
1490
  continue;
@@ -1293,8 +1528,7 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1293
1528
  if (!applied)
1294
1529
  return;
1295
1530
  if (this.selected?.id === go.id) {
1296
- this.invalidateOutlineBounds();
1297
- this.updateSelectionOutline();
1531
+ this.redrawOverlay();
1298
1532
  this.postSnapshotUpdate(go);
1299
1533
  }
1300
1534
  }
@@ -1311,7 +1545,8 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1311
1545
  }
1312
1546
  clearSelectionAndNotify(reason) {
1313
1547
  const hadSelection = this.selected != null;
1314
- this.clearSelection();
1548
+ this.selected = null;
1549
+ this.redrawOverlay();
1315
1550
  if (!shouldNotifyGameObjectDeselected(hadSelection)) {
1316
1551
  return;
1317
1552
  }
@@ -1324,44 +1559,6 @@ let CombosDevelopmentToolSystem = class CombosDevelopmentToolSystem extends Syst
1324
1559
  }
1325
1560
  this.game.emit(payload.type, { reason });
1326
1561
  }
1327
- clearSelection() {
1328
- this.selected = null;
1329
- this.invalidateOutlineBounds();
1330
- if (this.outlineGo?.parent) {
1331
- this.outlineGo.remove();
1332
- }
1333
- const gfx = this.outlineGo?.getComponent(Graphics);
1334
- if (gfx?.graphics) {
1335
- gfx.graphics.clear();
1336
- }
1337
- }
1338
- /** Redraw the green outline only when content bounds change (movement follows via child transform). */
1339
- updateSelectionOutline() {
1340
- if (!this.enabled || !this.selected || !this.outlineGo)
1341
- return;
1342
- const gfxComp = this.outlineGo.getComponent(Graphics);
1343
- if (!gfxComp?.graphics)
1344
- return;
1345
- const bounds = this.resolvePickBounds(this.selected);
1346
- if (this.lastOutlineBounds && this.pickBoundsEqual(this.lastOutlineBounds, bounds)) {
1347
- return;
1348
- }
1349
- this.lastOutlineBounds = bounds;
1350
- const g = gfxComp.graphics;
1351
- g.clear();
1352
- g.rect(bounds.x, bounds.y, bounds.width, bounds.height);
1353
- g.stroke({ width: 4, color: 0x55ffaa, alpha: 1 });
1354
- }
1355
- invalidateOutlineBounds() {
1356
- this.lastOutlineBounds = null;
1357
- }
1358
- pickBoundsEqual(a, b) {
1359
- const eps = 0.01;
1360
- return (Math.abs(a.x - b.x) < eps &&
1361
- Math.abs(a.y - b.y) < eps &&
1362
- Math.abs(a.width - b.width) < eps &&
1363
- Math.abs(a.height - b.height) < eps);
1364
- }
1365
1562
  };
1366
1563
  CombosDevelopmentToolSystem = __decorate([
1367
1564
  decorators.componentObserver({
@@ -1373,8 +1570,51 @@ var CombosDevelopmentToolSystem$1 = CombosDevelopmentToolSystem;
1373
1570
  /** Auto-generated by scripts/build-package.mjs — do not edit. */
1374
1571
  Object.assign(CombosDevelopmentToolSystem$1, {
1375
1572
  packageName: "@combos-fun/plugin-development-tool",
1376
- packageVersion: "0.0.47",
1573
+ packageVersion: "0.0.49",
1377
1574
  });
1378
1575
 
1379
- export { COMBOS_DEVELOPMENT_TOOL_APPLY_PROPERTY, COMBOS_DEVELOPMENT_TOOL_CLEAR_SELECTION, COMBOS_DEVELOPMENT_TOOL_GAMEOBJECT_DESELECTED, COMBOS_DEVELOPMENT_TOOL_GAMEOBJECT_SELECTED, COMBOS_DEVELOPMENT_TOOL_MARKER_OVERLAY_SUCCESS, COMBOS_DEVELOPMENT_TOOL_PICK_MODE_SUCCESS, COMBOS_DEVELOPMENT_TOOL_READY, COMBOS_DEVELOPMENT_TOOL_REFRESH, COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, COMBOS_DEVELOPMENT_TOOL_SET_MUTED, COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, COMBOS_DEVELOPMENT_TOOL_STATE_CHANGED, CombosDevelopmentToolSystem$1 as CombosDevelopmentToolSystem, CombosDevelopmentToolTarget, DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES, MARKER_COMPONENTS, MARKER_ICON_SIZE, applyPropertyValue, applyPropertyWithHooks, buildGameObjectDeselectedPayload, buildGameObjectSnapshot, drawMarkerIcon, isAllowedMessageOrigin, isClearSelectionMessage, mergeAllowedMessageOrigins, readPropertyValue, readSourceAnchor, resolveMarkerDef, shouldNotifyGameObjectDeselected };
1576
+ function isInternalEditorGo(go) {
1577
+ return typeof go.name === 'string' && go.name.startsWith('__');
1578
+ }
1579
+ function isSceneLike(go) {
1580
+ return Array.isArray(go.gameObjects);
1581
+ }
1582
+ function sourceIsComplete(raw) {
1583
+ if (!isSceneSourceAnchor(raw))
1584
+ return false;
1585
+ return Boolean(raw.file.trim()) && Boolean((raw.anchor ?? '').trim());
1586
+ }
1587
+ /**
1588
+ * Marks `go` for editor pick / persist. Idempotent: existing Targets keep a
1589
+ * complete `payload.source`; missing identity is filled from `source`.
1590
+ *
1591
+ * Inserted by `@combos-fun/plugin-development-tool/vite`. Game code should
1592
+ * not call this by hand.
1593
+ */
1594
+ function attachDevelopmentTarget(go, source) {
1595
+ if (!go || go.destroyed || isInternalEditorGo(go) || isSceneLike(go)) {
1596
+ return go;
1597
+ }
1598
+ const file = source.file.trim();
1599
+ const anchor = source.anchor.trim();
1600
+ if (!file || !anchor) {
1601
+ return go;
1602
+ }
1603
+ const existing = go.getComponent(CombosDevelopmentToolTarget);
1604
+ if (existing) {
1605
+ if (!sourceIsComplete(existing.payload?.source)) {
1606
+ existing.payload = {
1607
+ ...(existing.payload ?? {}),
1608
+ source: { file, anchor },
1609
+ };
1610
+ }
1611
+ return go;
1612
+ }
1613
+ go.addComponent(new CombosDevelopmentToolTarget({
1614
+ payload: { source: { file, anchor } },
1615
+ }));
1616
+ return go;
1617
+ }
1618
+
1619
+ export { COMBOS_DEVELOPMENT_TOOL_APPLY_PROPERTY, COMBOS_DEVELOPMENT_TOOL_CLEAR_SELECTION, COMBOS_DEVELOPMENT_TOOL_GAMEOBJECT_DESELECTED, COMBOS_DEVELOPMENT_TOOL_GAMEOBJECT_SELECTED, COMBOS_DEVELOPMENT_TOOL_MARKER_OVERLAY_SUCCESS, COMBOS_DEVELOPMENT_TOOL_PICK_MODE_SUCCESS, COMBOS_DEVELOPMENT_TOOL_READY, COMBOS_DEVELOPMENT_TOOL_REFRESH, COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, COMBOS_DEVELOPMENT_TOOL_SET_MUTED, COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, COMBOS_DEVELOPMENT_TOOL_STATE_CHANGED, CombosDevelopmentToolSystem$1 as CombosDevelopmentToolSystem, CombosDevelopmentToolTarget, DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES, MARKER_COMPONENTS, MARKER_ICON_SIZE, applyPropertyValue, applyPropertyWithHooks, attachDevelopmentTarget, buildGameObjectDeselectedPayload, buildGameObjectSnapshot, drawMarkerIcon, isAllowedMessageOrigin, isClearSelectionMessage, mergeAllowedMessageOrigins, readPropertyValue, readSourceAnchor, resolveMarkerDef, shouldNotifyGameObjectDeselected };
1380
1620
  //# sourceMappingURL=plugin-development-tool.esm.js.map