@volter/editor-game 0.5.67 → 0.5.68

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The display objects a canvas world creates, as its create dialog lists them: Pixi's own class
3
+ * tree (every one of them is a Container) and a line on what each is, from Pixi's API reference.
4
+ */
5
+
6
+ import type { CreatableKind } from '@volter/editor-project/adapter';
7
+
8
+ export const PIXI_CREATABLE_KINDS: readonly CreatableKind[] = [
9
+ {
10
+ kind: 'container',
11
+ label: 'Container',
12
+ description: 'A general-purpose display object that holds children, and transforms and draws them together.',
13
+ },
14
+ {
15
+ kind: 'sprite',
16
+ label: 'Sprite',
17
+ extends: 'container',
18
+ description: 'Draws one texture: an image, or a frame of a spritesheet.',
19
+ },
20
+ {
21
+ kind: 'animatedSprite',
22
+ label: 'AnimatedSprite',
23
+ extends: 'sprite',
24
+ description: 'Plays a list of textures as frames at an animation speed.',
25
+ },
26
+ {
27
+ kind: 'tilingSprite',
28
+ label: 'TilingSprite',
29
+ extends: 'container',
30
+ description: 'Repeats one texture across its width and height, scrollable by its tile position.',
31
+ },
32
+ {
33
+ kind: 'nineSliceSprite',
34
+ label: 'NineSliceSprite',
35
+ extends: 'container',
36
+ description: 'Stretches a texture without stretching its corners: the nine-slice panel of a UI.',
37
+ },
38
+ {
39
+ kind: 'text',
40
+ label: 'Text',
41
+ extends: 'container',
42
+ description: 'Draws a string with a text style (font, size, fill, wrapping).',
43
+ },
44
+ {
45
+ kind: 'bitmapText',
46
+ label: 'BitmapText',
47
+ extends: 'container',
48
+ description: 'Draws a string from a bitmap font: fast to change every frame.',
49
+ },
50
+ {
51
+ kind: 'htmlText',
52
+ label: 'HTMLText',
53
+ extends: 'container',
54
+ description: 'Draws a string of HTML and CSS markup.',
55
+ },
56
+ {
57
+ kind: 'graphics',
58
+ label: 'Graphics',
59
+ extends: 'container',
60
+ description: 'Draws vector shapes (rectangles, circles, paths) from its draw callback.',
61
+ },
62
+ ];
@@ -42,6 +42,7 @@ import {
42
42
  transform2DChanged,
43
43
  } from './pixi-transform-channels';
44
44
  import { createStructWritePipe, type StructOpOptions } from './struct-write-pipe';
45
+ import { PIXI_CREATABLE_KINDS } from './pixi-creatable-kinds';
45
46
  import {
46
47
  clipboardOutcome,
47
48
  LIVE_ONLY_ACK,
@@ -102,6 +103,7 @@ const PANEL_OWNED_PROPS = new Set([
102
103
  'scale-y',
103
104
  'label',
104
105
  'visible',
106
+ 'skew',
105
107
  ]);
106
108
  /** Props the drop snippet writes itself. */
107
109
  const DROP_SNIPPET_PROPS = new Set(['x', 'y', 'name', 'children']);
@@ -112,13 +114,14 @@ const CREATE_SNIPPETS: Readonly<Record<string, string>> = {
112
114
  sprite: '<pixiSprite label="Sprite" />',
113
115
  text: '<pixiText label="Text" text="Text" />',
114
116
  graphics: '<pixiGraphics label="Graphics" />',
117
+ animatedSprite: '<pixiAnimatedSprite label="AnimatedSprite" textures={[Texture.WHITE]} width={64} height={64} />',
118
+ tilingSprite: '<pixiTilingSprite label="TilingSprite" texture={Texture.WHITE} width={100} height={100} />',
119
+ nineSliceSprite: '<pixiNineSliceSprite label="NineSliceSprite" texture={Texture.WHITE} width={100} height={100} />',
120
+ bitmapText: '<pixiBitmapText label="BitmapText" text="BitmapText" />',
121
+ htmlText: '<pixiHTMLText label="HTMLText" text="HTMLText" />',
115
122
  };
116
- const CREATE_LABELS: Readonly<Record<string, string>> = {
117
- container: 'Container',
118
- sprite: 'Sprite',
119
- text: 'Text',
120
- graphics: 'Graphics',
121
- };
123
+ /** A kind whose snippet names Pixi's `Texture`, which the file must then import. */
124
+ const TEXTURE_SNIPPET_KINDS = new Set(['animatedSprite', 'tilingSprite', 'nineSliceSprite']);
122
125
 
123
126
  function isHiddenProp(name: string): boolean {
124
127
  return HIDDEN_PROPS.has(name) || /^on[A-Z]/.test(name) || name.startsWith('__vgai');
@@ -217,6 +220,10 @@ export function createSourceCanvasWriteTarget(
217
220
  const dynamicPaths = new Set<string>();
218
221
  /** Pre-gesture 2D poses, one per id currently under a drag. */
219
222
  const editStarts = new Map<string, Transform2DValue>();
223
+ const pendingOrigins = new Map<
224
+ string,
225
+ { kind: 'pivot' | 'anchor'; value: readonly [number, number]; before: readonly [number, number] }
226
+ >();
220
227
  let commitQueue = Promise.resolve();
221
228
 
222
229
  const oidOf = (id: string): string | null => {
@@ -464,10 +471,16 @@ export function createSourceCanvasWriteTarget(
464
471
  const channels = (['position', 'rotation', 'scale'] as const).filter((channel) =>
465
472
  transform2DChanged(channel, before, after),
466
473
  );
467
- if (channels.length === 0) return false;
468
-
469
- const revert = (channel: TransformChannel): void => {
470
- if (channel === 'position') a2d.setTransform(id, { position: before.position });
474
+ // A moved origin (pivot or anchor) is written with the position that compensates it.
475
+ const origin = pendingOrigins.get(id);
476
+ pendingOrigins.delete(id);
477
+ if (channels.length === 0 && !origin) return false;
478
+
479
+ const revert = (channel: TransformChannel | 'origin'): void => {
480
+ if (channel === 'origin') {
481
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
482
+ if (display && origin) display[origin.kind] = { x: origin.before[0], y: origin.before[1] };
483
+ } else if (channel === 'position') a2d.setTransform(id, { position: before.position });
471
484
  else if (channel === 'rotation') a2d.setTransform(id, { rotation: before.rotation });
472
485
  else a2d.setTransform(id, { scale: before.scale });
473
486
  notify();
@@ -495,7 +508,7 @@ export function createSourceCanvasWriteTarget(
495
508
  const instance = componentIdentityOf(id);
496
509
  const channelBlocked = (channel: TransformChannel): boolean =>
497
510
  componentOwnedChannel(id, channel, attrs, after);
498
- const writes: Array<{ channel: TransformChannel; prop: string; value: string }> = [];
511
+ const writes: Array<{ channel: TransformChannel | 'origin'; prop: string; value: string }> = [];
499
512
  for (const channel of channels) {
500
513
  if (channelBlocked(channel)) {
501
514
  dynamicPaths.add(`${id}|${channel}`);
@@ -532,6 +545,16 @@ export function createSourceCanvasWriteTarget(
532
545
  }
533
546
  for (const write of plan.writes) writes.push({ channel, ...write });
534
547
  }
548
+ if (origin) {
549
+ if (instance && channelBlocked('position')) revert('origin');
550
+ else {
551
+ writes.push({
552
+ channel: 'origin',
553
+ prop: origin.kind,
554
+ value: `{ x: ${formatSourceNumber(origin.value[0])}, y: ${formatSourceNumber(origin.value[1])} }`,
555
+ });
556
+ }
557
+ }
535
558
  if (writes.length === 0) return false;
536
559
 
537
560
  let persisted = false;
@@ -543,7 +566,8 @@ export function createSourceCanvasWriteTarget(
543
566
  // must not need a trip to the text editor.
544
567
  addIfMissing: true,
545
568
  // `scale={1.5}` becomes `scale={{ x, y }}` when a gesture makes it non-uniform.
546
- ...(write.prop === 'scale' ? { allowShapeUpgrade: true } : {}),
569
+ // The same for an origin authored as one number (`pivot={60}`).
570
+ ...(write.prop === 'scale' || write.channel === 'origin' ? { allowShapeUpgrade: true } : {}),
547
571
  });
548
572
  if (res.changed) {
549
573
  persisted = true;
@@ -595,6 +619,38 @@ export function createSourceCanvasWriteTarget(
595
619
 
596
620
  /** One prop edit. Answers whether a byte reached the entry TSX; every
597
621
  * refusal has already named itself on the console before it returns. */
622
+ /** Godot's Node2D Skew, one axis in degrees: Pixi's `skew` point in radians, written whole. */
623
+ const writeSkew = async (id: string, axis: 'x' | 'y', degrees: number): Promise<boolean> => {
624
+ const display = a2d.displayObject(id);
625
+ const oid = oidOf(id);
626
+ if (!display || !oid || !backend?.writeProp || !Number.isFinite(degrees)) return false;
627
+ const before = { x: display.skew.x, y: display.skew.y };
628
+ const radians = (degrees * Math.PI) / 180;
629
+ const next = { x: axis === 'x' ? radians : before.x, y: axis === 'y' ? radians : before.y };
630
+ display.skew.set(next.x, next.y);
631
+ notify();
632
+ const res = await backend.writeProp(
633
+ oid,
634
+ 'skew',
635
+ // Radians to six places, so a typed angle reads back as typed (four places lose 0.003°).
636
+ `{ x: ${String(Number(next.x.toFixed(6)))}, y: ${String(Number(next.y.toFixed(6)))} }`,
637
+ { addIfMissing: true, allowShapeUpgrade: true },
638
+ );
639
+ if (!res.changed && (res.dynamic || res.error)) {
640
+ display.skew.set(before.x, before.y);
641
+ notify();
642
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
643
+ console.warn(
644
+ `[canvas-source ${entryPath}] skew write refused for oid "${oid}": ` +
645
+ `${res.dynamic ? 'dynamic expression (guarded)' : res.error} — reverting.`,
646
+ );
647
+ return false;
648
+ }
649
+ await refreshSourceState();
650
+ notify();
651
+ return res.changed;
652
+ };
653
+
598
654
  const writeJsxProp = async (
599
655
  id: string,
600
656
  path: string,
@@ -1130,12 +1186,20 @@ export function createSourceCanvasWriteTarget(
1130
1186
  // The new element's oid is minted server-side on the remount, so the id
1131
1187
  // half is '' and the ack half is this creation's own piped write — handed
1132
1188
  // back rather than fired `void` (see `StructuralIdWrite`).
1133
- create: (kind, parentId) => {
1134
- const snippet = CREATE_SNIPPETS[kind];
1189
+ create: (kind, parentId, at) => {
1190
+ // `at` arrives in the new node's parent's own space (the adapter converts it).
1191
+ const base = CREATE_SNIPPETS[kind];
1192
+ const snippet =
1193
+ base && at
1194
+ ? base.replace(/\s*\/>$/, ` x={${formatSourceNumber(at.x)}} y={${formatSourceNumber(at.y)}} />`)
1195
+ : base;
1135
1196
  // Nothing was attempted (the palette is empty in both cases), so there is
1136
1197
  // no write to ack.
1137
1198
  if (!snippet || !backend?.writeStruct) return { id: '', ack: undefined };
1138
- if (parentId) return { id: '', ack: structOp(parentId, 'create', { snippet }) };
1199
+ const options = TEXTURE_SNIPPET_KINDS.has(kind)
1200
+ ? { snippet, ensureImport: { name: 'Texture', module: 'pixi.js' } }
1201
+ : { snippet };
1202
+ if (parentId) return { id: '', ack: structOp(parentId, 'create', options) };
1139
1203
  const lastRoot = a2d.roots().at(-1);
1140
1204
  if (!lastRoot) {
1141
1205
  return {
@@ -1145,15 +1209,14 @@ export function createSourceCanvasWriteTarget(
1145
1209
  ),
1146
1210
  };
1147
1211
  }
1148
- return { id: '', ack: structOp(lastRoot.id, 'create-sibling', { snippet }) };
1212
+ return { id: '', ack: structOp(lastRoot.id, 'create-sibling', options) };
1149
1213
  },
1150
1214
  creatableKinds: (parentId) => {
1151
1215
  if (!backend?.writeStruct) return [];
1152
1216
  if (parentId && !oidOf(parentId)) return [];
1153
1217
  if (!parentId && a2d.roots().length === 0) return [];
1154
- return Object.keys(CREATE_SNIPPETS).map((kind) => ({
1155
- kind,
1156
- label: CREATE_LABELS[kind] ?? kind,
1218
+ return PIXI_CREATABLE_KINDS.filter((entry) => CREATE_SNIPPETS[entry.kind] !== undefined).map((entry) => ({
1219
+ ...entry,
1157
1220
  }));
1158
1221
  },
1159
1222
  // Returns the promise (never `void structOp(…)`): `deleteSelection`
@@ -1469,6 +1532,21 @@ export function createSourceCanvasWriteTarget(
1469
1532
  if (!editStarts.has(id)) editStarts.set(id, read2D(id));
1470
1533
  },
1471
1534
 
1535
+ /**
1536
+ * Mid-gesture origin write — see {@link CanvasWriteTarget.writeOrigin}. The live object moves
1537
+ * now; the `pivot`/`anchor` prop is written at `endTransformEdit`, in the same gesture as the
1538
+ * position that compensates it.
1539
+ */
1540
+ writeOrigin(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void {
1541
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
1542
+ const current = display?.[kind] as { x?: unknown; y?: unknown } | undefined;
1543
+ if (!display || typeof current?.x !== 'number' || typeof current.y !== 'number') return;
1544
+ const before = pendingOrigins.get(id)?.before ?? ([current.x, current.y] as const);
1545
+ display[kind] = { x: value[0], y: value[1] };
1546
+ pendingOrigins.set(id, { kind, value, before });
1547
+ notify();
1548
+ },
1549
+
1472
1550
  writeTransform(id: string, next: Transform2DValue): void {
1473
1551
  // The live tree moves first so the paused frame follows the gesture; the
1474
1552
  // source write happens once, at the end.
@@ -1535,6 +1613,13 @@ export function createSourceCanvasWriteTarget(
1535
1613
  { path: 'visible', label: 'Visible', type: 'boolean', group: 'Visibility' },
1536
1614
  ];
1537
1615
  const attrs = attrsOf(id) ?? [];
1616
+ // Godot's Node2D Skew, in its Transform group; a skew the source computes is shown, not edited.
1617
+ const skewAttr = attrs.find((attr) => attr.name === 'skew');
1618
+ const skewFixed = skewAttr ? !skewAttr.isLiteral : false;
1619
+ props.push(
1620
+ { path: 'skew.x', label: 'Skew X (deg)', type: 'number', group: 'Transform', readonly: skewFixed },
1621
+ { path: 'skew.y', label: 'Skew Y (deg)', type: 'number', group: 'Transform', readonly: skewFixed },
1622
+ );
1538
1623
  const declared = declaredPropsOf(id);
1539
1624
  const attrByName = new Map(attrs.map((attr) => [attr.name, attr]));
1540
1625
  const names = [
@@ -1573,6 +1658,9 @@ export function createSourceCanvasWriteTarget(
1573
1658
  if (echoed !== undefined) return echoed;
1574
1659
  if (path === 'name') return display.label ?? '';
1575
1660
  if (path === 'visible') return display.visible;
1661
+ if (path === 'skew.x' || path === 'skew.y') {
1662
+ return Number((((path === 'skew.x' ? display.skew.x : display.skew.y) * 180) / Math.PI).toFixed(4));
1663
+ }
1576
1664
  if (!path.startsWith('jsx.')) return undefined;
1577
1665
  return propValue(attrsOf(id), declaredPropsOf(id), path.slice(4));
1578
1666
  },
@@ -1581,6 +1669,13 @@ export function createSourceCanvasWriteTarget(
1581
1669
  // edit, awaited. A path this target owns no prop for performed no write and
1582
1670
  // returns nothing.
1583
1671
  set(id: string, path: string, value: unknown): void | Promise<WriteAck> {
1672
+ if (path === 'skew.x' || path === 'skew.y') {
1673
+ return piped(
1674
+ () => writeSkew(id, path === 'skew.x' ? 'x' : 'y', Number(value)),
1675
+ undefined,
1676
+ destinationOf(id),
1677
+ );
1678
+ }
1584
1679
  const prop =
1585
1680
  path === 'name'
1586
1681
  ? 'label'
@@ -197,7 +197,7 @@ export function PixiIsolationSceneContent({
197
197
  }}
198
198
  >
199
199
  <CanvasSceneBackdrop view={view} documentId={documentId} />
200
- {adapter ? <RootSelectionOverlay adapter={adapter} view={view} transformModeAware /> : null}
200
+ {adapter ? <RootSelectionOverlay adapter={adapter} view={view} transformModeAware presentationId={documentId} /> : null}
201
201
  <CanvasSceneControls
202
202
  active={active}
203
203
  {...(adapter ? { adapter } : {})}
@@ -307,6 +307,18 @@ function canvasWorldAdapter(
307
307
  // the host only gates/advances it.
308
308
  mountedApp.ticker.update(elapsedMs);
309
309
  },
310
+ // Pixi's own hit test finds only interactive objects (`eventMode` static or dynamic), so a
311
+ // HUD claims its buttons and lets every other point fall through to the world beneath.
312
+ hitTest(x: number, y: number): boolean {
313
+ if (disposed) return false;
314
+ // Pixi points its boundary at the last rendered scene inside each of its own pointer
315
+ // handlers; a hit test asked from outside them does the same, or finds no root.
316
+ const boundary = mountedApp.renderer.events.rootBoundary;
317
+ const rendered = mountedApp.renderer.lastObjectRendered;
318
+ if (!rendered) return false;
319
+ boundary.rootTarget = rendered;
320
+ return boundary.hitTest(x, y) !== null;
321
+ },
310
322
  resize(width: number, height: number): void {
311
323
  if (disposed) return;
312
324
  // `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
@@ -172,6 +172,8 @@ export const BUNDLE_RUNTIME_MODULE_LOADERS: Record<string, () => Promise<unknown
172
172
  // against the same sweep.
173
173
  '@colyseus/sdk': () => import('@colyseus/sdk'), // template main.ts
174
174
  'react-dom': () => import('react-dom'), // template main.ts
175
+ 'pixi.js': () => import('pixi.js'), // template main.ts (a `canvas` root)
176
+ '@pixi/react': () => import('@pixi/react'), // template main.ts (a `canvas` root)
175
177
  // ---- Editor-citizen modules PROJECT TOOL CONTRIBUTIONS import. A
176
178
  // contribution is project source rendered inside the editor's own panels,
177
179
  // so these must resolve to the editor's LIVE modules — a second copy of the
@@ -148,7 +148,7 @@ export function IngestCanvasSceneContent({
148
148
  }}
149
149
  >
150
150
  <CanvasSceneBackdrop view={view} documentId={documentId} />
151
- <RootSelectionOverlay adapter={adapter} view={view} transformModeAware />
151
+ <RootSelectionOverlay adapter={adapter} view={view} transformModeAware presentationId={documentId} />
152
152
  <SurfaceStateOverlay
153
153
  explanation={surfaceExplanation}
154
154
  testId="ingest-canvas-surface-status"