@combos-fun/plugin-development-tool 0.0.53 → 0.1.0

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.
package/agent-skill.md CHANGED
@@ -56,13 +56,13 @@ export default defineConfig({
56
56
  });
57
57
  ```
58
58
 
59
- Do not add `CombosDevelopmentToolTarget` from game code. Opt out of a construct with `// combos-no-target` or `{ editor: { pickable: false } }` on the `GameObject` params object (the extra key is ignored by `Transform`).
59
+ Do not add `CombosDevelopmentToolTarget` from game code. Opt out of a construct with `// combos-no-target`.
60
60
 
61
61
  ## Runtime behaviour
62
62
 
63
- The System starts disabled. In pick mode it shows a full-canvas overlay (`pointer-events: auto`) so game `Event` / `Event3D` do not receive the tap. Targets are hit-tested from engine transforms projected to overlay CSS pixels. Target additions/removals are observed automatically; `requestSceneRescan()` is for parent-driven structural refreshes.
63
+ The System starts disabled. In pick mode it shows a full-canvas overlay (`pointer-events: auto`) so game `Event` / `Event3D` do not receive the tap, and it also listens on `window` capture so a DOM HUD above the canvas cannot swallow the tap. Targets are hit-tested from engine transforms projected to overlay CSS pixels. Overlapping hits prefer content: hollow / invisible / low-alpha nodes and rects covering ≥75% of the overlay lose to a smaller target underneath; two covering layers pick the earlier scene-walk item. Target additions/removals are observed automatically; `requestSceneRescan()` is for parent-driven structural refreshes.
64
64
 
65
- 2D bounds come from `Transform` size / hierarchy (position, origin, anchor, scale, rotation). Set `transform.size` when the visual does not match a zero size. 3D bounds project `Transform3D` world position through the active `Renderer3DSystem` camera; a mesh bounding box is used when the Three object is available, otherwise a screen-space pad around the projected point.
65
+ 2D bounds come from `Transform2D` size plus `GameObject` hierarchy (position, origin, anchor, scale, rotation). Set `Transform2D.size` when the visual does not match a zero size. 3D bounds project `Transform3D` world position through the active `Renderer3DSystem` camera; a mesh bounding box is used when the Three object is available, otherwise a screen-space pad around the projected point.
66
66
 
67
67
  The marker overlay is independent of pick mode and mute. It currently draws a 28×28 canvas badge only for `Sound`; the owner still needs a Target for selection and persistence (the Vite plugin attaches that Target).
68
68
 
@@ -90,8 +90,10 @@ Snapshots expose described schema-backed `fieldDescriptors`, not raw fields. For
90
90
 
91
91
  ## Common pitfalls
92
92
 
93
- - 2D picking needs a positive `transform.size` to get a usable hit rect.
93
+ - 2D picking needs a positive `Transform2D.size` to get a usable hit rect.
94
94
  - 3D picking needs `Renderer3DSystem` (for the camera) at runtime; there is no package dependency.
95
+ - Pick ranking is not pixel-perfect: a fullscreen `Img` with `alpha: 1` still counts as drawable even if the PNG is mostly transparent.
96
+ - A covering overlay is still selected when it is the only hit under the pointer.
95
97
  - Parent messages require an iframe (`window.parent !== window`) and an allowed origin.
96
98
  - Structural replacement needs `refresh`; ordinary Target add/remove does not.
97
99
  - Persistence fails if the game build did not run `combosDevelopmentTargetPlugin`.
@@ -255,14 +255,17 @@ function mergeAllowedMessageOrigins(extra) {
255
255
 
256
256
  const OVERLAY_Z_INDEX = '2147483000';
257
257
  const OVERLAY_ATTR = 'data-combos-development-tool-overlay';
258
- function ensureParent(gameCanvas, overlay) {
259
- const parent = gameCanvas.parentElement ?? document.body;
258
+ function ensureParent(_gameCanvas, overlay) {
259
+ const parent = document.body;
260
+ if (!parent)
261
+ return;
260
262
  if (overlay.parentElement !== parent) {
261
263
  parent.appendChild(overlay);
262
264
  }
263
265
  }
264
266
  /**
265
- * Transparent HTML canvas stacked on the game canvas.
267
+ * Transparent HTML canvas stacked on the game canvas and mounted on `document.body`
268
+ * so a transformed canvas parent cannot trap `position: fixed` under a DOM HUD.
266
269
  * Pick mode sets `pointer-events: auto` so game Event / Event3D do not receive the tap.
267
270
  */
268
271
  function createOverlayCanvas() {
@@ -498,7 +501,7 @@ function leafDescriptorsFromValue(componentName, options, path, value) {
498
501
  group: options.group ?? componentName,
499
502
  editorKind: mapEditorKind(options, axisType),
500
503
  numberMeta: buildNumberMeta(options),
501
- persistTarget: componentName === 'Transform' ? 'gameObjectConstructor' : 'component',
504
+ persistTarget: 'component',
502
505
  });
503
506
  }
504
507
  return out;
@@ -521,7 +524,7 @@ function leafDescriptorsFromValue(componentName, options, path, value) {
521
524
  editorKind: mapEditorKind(options, valueType),
522
525
  enumOptions: options.enumOptions,
523
526
  numberMeta: buildNumberMeta(options),
524
- persistTarget: componentName === 'Transform' ? 'gameObjectConstructor' : 'component',
527
+ persistTarget: 'component',
525
528
  },
526
529
  ];
527
530
  }
@@ -618,7 +621,10 @@ function syncPhysicsBodyFromTransform(go) {
618
621
  if (!physics?.body) {
619
622
  return;
620
623
  }
621
- const { x, y } = go.transform.position;
624
+ const pose = go.getComponent('Transform2D');
625
+ if (!pose)
626
+ return;
627
+ const { x, y } = pose.position;
622
628
  if (physics.Body?.setPosition) {
623
629
  physics.Body.setPosition(physics.body, { x, y });
624
630
  return;
@@ -631,11 +637,16 @@ function syncMoverOriginFromTransform(go) {
631
637
  if (!mover) {
632
638
  return;
633
639
  }
634
- mover.originX = go.transform.position.x;
635
- mover.originY = go.transform.position.y;
640
+ const pose = go.getComponent('Transform2D');
641
+ if (!pose)
642
+ return;
643
+ mover.originX = pose.position.x;
644
+ mover.originY = pose.position.y;
636
645
  }
637
646
  function applyTransformProperty(go, _comp, path, value) {
638
- const transform = go.transform;
647
+ const transform = go.getComponent('Transform2D');
648
+ if (!transform)
649
+ return false;
639
650
  const transformRecord = transform;
640
651
  if (path.length === 1 && path[0] === 'rotation') {
641
652
  const next = asNumber(value);
@@ -693,7 +704,7 @@ function applySoundProperty(_go, comp, path, value) {
693
704
  return false;
694
705
  }
695
706
  const APPLY_HOOKS = {
696
- Transform: applyTransformProperty,
707
+ Transform2D: applyTransformProperty,
697
708
  Text: applyTextProperty,
698
709
  Sound: applySoundProperty,
699
710
  };
@@ -977,6 +988,32 @@ function projectTransform3D(transform, camera, css, object3D) {
977
988
  const scale = Math.max(Math.abs(transform.scaleX ?? 1), Math.abs(transform.scaleY ?? 1), Math.abs(transform.scaleZ ?? 1), 1);
978
989
  return padAround(projected, DEFAULT_POINT_EXTENT * scale);
979
990
  }
991
+ function asTransform2DPose(value) {
992
+ if (!value || typeof value !== 'object')
993
+ return null;
994
+ const t = value;
995
+ if (!t.position || !t.size)
996
+ return null;
997
+ return {
998
+ position: t.position,
999
+ size: t.size,
1000
+ origin: t.origin ?? { x: 0, y: 0 },
1001
+ anchor: t.anchor ?? { x: 0, y: 0 },
1002
+ scale: t.scale ?? { x: 1, y: 1 },
1003
+ skew: t.skew ?? { x: 0, y: 0 },
1004
+ rotation: t.rotation ?? 0,
1005
+ };
1006
+ }
1007
+ function pose2DFromGameObject(go) {
1008
+ const pose = asTransform2DPose(go.getComponent('Transform2D'));
1009
+ if (!pose)
1010
+ return null;
1011
+ const parentGo = go.parent && go.parent !== go.scene ? go.parent : null;
1012
+ return {
1013
+ ...pose,
1014
+ parent: parentGo ? pose2DFromGameObject(parentGo) : null,
1015
+ };
1016
+ }
980
1017
  function projectGameObjectToScreen(go, opts) {
981
1018
  if (opts.css.width <= 0 || opts.css.height <= 0) {
982
1019
  return null;
@@ -985,21 +1022,108 @@ function projectGameObjectToScreen(go, opts) {
985
1022
  if (t3d && opts.camera) {
986
1023
  return projectTransform3D(t3d, opts.camera, opts.css, opts.object3D);
987
1024
  }
988
- return projectTransform2D(go.transform, opts.css, opts.design ?? null);
1025
+ const t2d = pose2DFromGameObject(go);
1026
+ if (!t2d)
1027
+ return null;
1028
+ return projectTransform2D(t2d, opts.css, opts.design ?? null);
989
1029
  }
990
1030
  function rectContains(rect, x, y) {
991
1031
  return x >= rect.x && y >= rect.y && x <= rect.x + rect.width && y <= rect.y + rect.height;
992
1032
  }
993
- /** Smallest containing rect wins; later items win ties (front-most in walk order). */
994
- function hitTestRects(x, y, items) {
1033
+ /** Fraction of the overlay a rect must cover to count as a fullscreen pass-through layer. */
1034
+ const COVERING_OVERLAY_RATIO = 0.75;
1035
+ /** `Render.alpha` / `Render3D.opacity` at or below this is treated as see-through. */
1036
+ const LOW_ALPHA_PASSTHROUGH = 0.35;
1037
+ /**
1038
+ * Known drawable component names. Duck-typed so this package does not import
1039
+ * renderer plugins. Unknown custom visuals are treated as non-drawable only for
1040
+ * pass-through ranking; they remain pickable when they are the only hit.
1041
+ */
1042
+ const KNOWN_VISUAL_COMPONENT_NAMES = [
1043
+ 'Img',
1044
+ 'Sprite',
1045
+ 'TilingSprite',
1046
+ 'NinePatch',
1047
+ 'Text',
1048
+ 'Graphics',
1049
+ 'SpriteAnimation',
1050
+ 'Img3D',
1051
+ 'Sprite3D',
1052
+ 'TilingSprite3D',
1053
+ 'Text3D',
1054
+ 'Graphics3D',
1055
+ 'Model3D',
1056
+ 'SpriteAnimation3D',
1057
+ ];
1058
+ function rectOverlapArea(a, b) {
1059
+ const width = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
1060
+ const height = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
1061
+ if (width <= 0 || height <= 0)
1062
+ return 0;
1063
+ return width * height;
1064
+ }
1065
+ function isCoveringOverlay(rect, viewport, ratio = COVERING_OVERLAY_RATIO) {
1066
+ if (!viewport || viewport.width <= 0 || viewport.height <= 0) {
1067
+ return false;
1068
+ }
1069
+ const viewportArea = viewport.width * viewport.height;
1070
+ if (viewportArea <= 0)
1071
+ return false;
1072
+ return rectOverlapArea(rect, { x: 0, y: 0, width: viewport.width, height: viewport.height }) / viewportArea >= ratio;
1073
+ }
1074
+ function hasKnownVisual(go) {
1075
+ return KNOWN_VISUAL_COMPONENT_NAMES.some(name => Boolean(go.getComponent(name)));
1076
+ }
1077
+ /**
1078
+ * Higher = more likely to click through to a target underneath.
1079
+ * Invisible / zero-alpha layers rank above hollow containers and low-alpha dimmers.
1080
+ */
1081
+ function pickPassThrough(go) {
1082
+ const render = go.getComponent('Render');
1083
+ const render3d = go.getComponent('Render3D');
1084
+ const visible = render?.visible !== false && render3d?.visible !== false;
1085
+ const alpha = Math.min(typeof render?.alpha === 'number' && Number.isFinite(render.alpha) ? render.alpha : 1, typeof render3d?.opacity === 'number' && Number.isFinite(render3d.opacity) ? render3d.opacity : 1);
1086
+ let score = 0;
1087
+ if (!visible || alpha <= 0)
1088
+ score += 2;
1089
+ else if (alpha <= LOW_ALPHA_PASSTHROUGH)
1090
+ score += 1;
1091
+ if (!hasKnownVisual(go))
1092
+ score += 1;
1093
+ return score;
1094
+ }
1095
+ function isBetterHit(next, best) {
1096
+ if (next.score !== best.score)
1097
+ return next.score < best.score;
1098
+ if (next.covering !== best.covering)
1099
+ return !next.covering;
1100
+ if (next.covering) {
1101
+ return next.index < best.index;
1102
+ }
1103
+ if (next.area !== best.area)
1104
+ return next.area < best.area;
1105
+ return next.index > best.index;
1106
+ }
1107
+ /**
1108
+ * Pick among overlapping targets:
1109
+ * - lower pass-through score wins (content over invisible / hollow / low-alpha);
1110
+ * - a covering fullscreen layer loses to any non-covering hit;
1111
+ * - among covering-only hits, the earlier scene-walk item wins (what's behind);
1112
+ * - among content hits, smallest rect wins, later items win ties (front-most).
1113
+ */
1114
+ function hitTestRects(x, y, items, opts) {
995
1115
  let best = null;
1116
+ const coveringRatio = opts?.coveringRatio ?? COVERING_OVERLAY_RATIO;
996
1117
  for (let i = 0; i < items.length; i++) {
997
1118
  const item = items[i];
998
1119
  if (!rectContains(item.rect, x, y))
999
1120
  continue;
1000
1121
  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 };
1122
+ const covering = isCoveringOverlay(item.rect, opts?.viewport, coveringRatio);
1123
+ const score = (item.passThrough ?? 0) + (covering ? 1 : 0);
1124
+ const next = { id: item.id, score, covering, area, index: i };
1125
+ if (!best || isBetterHit(next, best)) {
1126
+ best = next;
1003
1127
  }
1004
1128
  }
1005
1129
  return best ? best.id : null;
@@ -1011,8 +1135,10 @@ const OUTLINE_COLOR = '#55ffaa';
1011
1135
  /**
1012
1136
  * **Off** at start. While enabled:
1013
1137
  * - a transparent HTML canvas overlay captures pointer events (game Event / Event3D
1014
- * do not receive the tap);
1138
+ * do not receive the tap); pick also listens on `window` capture so a mostly
1139
+ * transparent DOM HUD above the canvas cannot swallow the tap;
1015
1140
  * - only nodes with {@link CombosDevelopmentToolTarget} are pickable;
1141
+ * - fullscreen / hollow / low-alpha layers lose to content underneath;
1016
1142
  * - selection outline and markers are drawn with the Canvas 2D API (no renderer plugin).
1017
1143
  *
1018
1144
  * Turn on/off via:
@@ -1040,6 +1166,7 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1040
1166
  this.overlay = null;
1041
1167
  this.overlayCss = { width: 0, height: 0 };
1042
1168
  this.pointerDownGoId = null;
1169
+ this.pickListening = false;
1043
1170
  this.needsRescan = false;
1044
1171
  /** Remembered mute when `SoundSystem` is not registered yet. */
1045
1172
  this.mutedWithoutSoundSystem = false;
@@ -1105,10 +1232,11 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1105
1232
  this.requestSceneRescan();
1106
1233
  };
1107
1234
  this.onPointerDown = (e) => {
1108
- if (!this.enabled)
1235
+ if (!this.enabled || !this.isPickPointInGame(e))
1109
1236
  return;
1110
1237
  e.preventDefault();
1111
1238
  e.stopPropagation();
1239
+ e.stopImmediatePropagation();
1112
1240
  try {
1113
1241
  this.overlay?.canvas.setPointerCapture(e.pointerId);
1114
1242
  }
@@ -1121,8 +1249,13 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1121
1249
  this.onPointerUp = (e) => {
1122
1250
  if (!this.enabled)
1123
1251
  return;
1252
+ if (!this.isPickPointInGame(e)) {
1253
+ this.pointerDownGoId = null;
1254
+ return;
1255
+ }
1124
1256
  e.preventDefault();
1125
1257
  e.stopPropagation();
1258
+ e.stopImmediatePropagation();
1126
1259
  const hit = this.hitTestPointer(e);
1127
1260
  if (hit && hit.id === this.pointerDownGoId) {
1128
1261
  this.onSelect(hit, e);
@@ -1135,10 +1268,6 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1135
1268
  this.postMessageOrigin = params?.postMessageOrigin ?? '*';
1136
1269
  this.allowedMessageOrigins = mergeAllowedMessageOrigins(params?.allowedMessageOrigins);
1137
1270
  this.overlay = createOverlayCanvas();
1138
- if (this.overlay) {
1139
- this.overlay.canvas.addEventListener('pointerdown', this.onPointerDown);
1140
- this.overlay.canvas.addEventListener('pointerup', this.onPointerUp);
1141
- }
1142
1271
  if (typeof window !== 'undefined') {
1143
1272
  window.addEventListener(COMBOS_DEVELOPMENT_TOOL_SET_PICK_MODE, this.onWindowSetPickMode);
1144
1273
  window.addEventListener(COMBOS_DEVELOPMENT_TOOL_SET_MARKER_OVERLAY, this.onWindowSetMarkerOverlay);
@@ -1154,9 +1283,8 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1154
1283
  }
1155
1284
  }
1156
1285
  onDestroy() {
1286
+ this.unbindPickListeners();
1157
1287
  if (this.overlay) {
1158
- this.overlay.canvas.removeEventListener('pointerdown', this.onPointerDown);
1159
- this.overlay.canvas.removeEventListener('pointerup', this.onPointerUp);
1160
1288
  this.overlay.destroy();
1161
1289
  this.overlay = null;
1162
1290
  }
@@ -1273,20 +1401,52 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1273
1401
  const active = this.enabled || this.markerOverlayEnabled;
1274
1402
  this.overlay?.setVisible(active);
1275
1403
  this.overlay?.setPickEnabled(this.enabled);
1404
+ if (this.enabled) {
1405
+ this.bindPickListeners();
1406
+ }
1407
+ else {
1408
+ this.unbindPickListeners();
1409
+ }
1410
+ }
1411
+ bindPickListeners() {
1412
+ if (this.pickListening || typeof window === 'undefined')
1413
+ return;
1414
+ window.addEventListener('pointerdown', this.onPointerDown, true);
1415
+ window.addEventListener('pointerup', this.onPointerUp, true);
1416
+ this.pickListening = true;
1417
+ }
1418
+ unbindPickListeners() {
1419
+ if (!this.pickListening || typeof window === 'undefined')
1420
+ return;
1421
+ window.removeEventListener('pointerdown', this.onPointerDown, true);
1422
+ window.removeEventListener('pointerup', this.onPointerUp, true);
1423
+ this.pickListening = false;
1424
+ }
1425
+ isPickPointInGame(e) {
1426
+ const canvas = this.game.canvas ?? this.game.scene?.canvas ?? this.overlay?.canvas;
1427
+ if (!canvas)
1428
+ return false;
1429
+ const rect = canvas.getBoundingClientRect();
1430
+ return (rect.width > 0 &&
1431
+ rect.height > 0 &&
1432
+ e.clientX >= rect.left &&
1433
+ e.clientX <= rect.right &&
1434
+ e.clientY >= rect.top &&
1435
+ e.clientY <= rect.bottom);
1276
1436
  }
1277
1437
  collectGameObjects(go, out) {
1278
1438
  out.push(go);
1279
- for (const tr of go.transform.children) {
1280
- this.collectGameObjects(tr.gameObject, out);
1439
+ for (const child of go.children) {
1440
+ this.collectGameObjects(child, out);
1281
1441
  }
1282
1442
  }
1283
1443
  listSceneObjects() {
1284
1444
  const list = [];
1285
1445
  const scene = this.game.scene;
1286
- if (!scene?.transform?.children)
1446
+ if (!scene?.children)
1287
1447
  return list;
1288
- for (const tr of scene.transform.children) {
1289
- this.collectGameObjects(tr.gameObject, list);
1448
+ for (const child of scene.children) {
1449
+ this.collectGameObjects(child, list);
1290
1450
  }
1291
1451
  return list;
1292
1452
  }
@@ -1312,13 +1472,14 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1312
1472
  if (!go.getComponent(CombosDevelopmentToolTarget))
1313
1473
  continue;
1314
1474
  const rect = this.projectGo(go);
1475
+ const passThrough = pickPassThrough(go);
1315
1476
  if (rect) {
1316
- hits.push({ go, rect });
1477
+ hits.push({ go, rect, passThrough });
1317
1478
  }
1318
1479
  if (this.markerOverlayEnabled && resolveMarkerDef(go)) {
1319
1480
  const markerRect = this.markerRectFor(go, rect);
1320
1481
  if (markerRect) {
1321
- hits.push({ go, rect: markerRect });
1482
+ hits.push({ go, rect: markerRect, passThrough });
1322
1483
  }
1323
1484
  }
1324
1485
  }
@@ -1353,7 +1514,7 @@ let CombosDevelopmentToolSystem$1 = class CombosDevelopmentToolSystem extends en
1353
1514
  hitTestPointer(e) {
1354
1515
  const pt = this.overlayPoint(e);
1355
1516
  const hits = this.collectOverlayHits();
1356
- return hitTestRects(pt.x, pt.y, hits.map(h => ({ id: h.go, rect: h.rect })));
1517
+ return hitTestRects(pt.x, pt.y, hits.map(h => ({ id: h.go, rect: h.rect, passThrough: h.passThrough })), { viewport: this.overlayCss });
1357
1518
  }
1358
1519
  redrawOverlay() {
1359
1520
  const overlay = this.overlay;
@@ -1572,7 +1733,7 @@ var CombosDevelopmentToolSystem = CombosDevelopmentToolSystem$1;
1572
1733
  /** Auto-generated by scripts/build-package.mjs — do not edit. */
1573
1734
  Object.assign(CombosDevelopmentToolSystem, {
1574
1735
  packageName: "@combos-fun/plugin-development-tool",
1575
- packageVersion: "0.0.53",
1736
+ packageVersion: "0.1.0",
1576
1737
  });
1577
1738
 
1578
1739
  function isInternalEditorGo(go) {