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