A sprite or collider could only be sized, turned and scaled through
the inspector. The scene view now draws a size gizmo on the selection,
so the common edits happen where the object is.
- Edges and corners set Width and Height. The opposite side stays
where it was, so the position moves by half the change. Sizes land
on whole units, at least 1.
- A circle collider has edge handles only and stays round. A polygon
or frame collider keeps its own corner editing and gets no size
handles.
- One knob on a stem above the top edge turns or scales. The first
5 px of the drag decide which: out from the centre scales, sideways
turns. The choice holds until the release, so one drag never does
both. Turning and scaling share one handle by the user's choice.
- Shift snaps a turn to 15 degrees. The turn adds up the swept angle,
so it goes on past a half circle instead of jumping back.
- The values are overrides of this placement, like "Fit to sprite".
A value equal to the asset's own clears the override. One drag is
one undo step.
- A handle reaches 7 px outwards but only half that into the object.
Otherwise a press near the edge of a small sprite sizes it instead
of moving it.
- The rules live in src/editor/sizeGizmo.ts without Pixi. The handles
are drawn and driven by src/editor/viewport/sizeGizmoEditor.ts. The
test hook __gm.sizeGizmo() reports them in screen pixels.
Found on the way:
- The first e2e run failed the play-mode drag check in 41b. It grabs
the 40 px crate 6 px inside its bottom edge, and the full grip zone
took that as a resize. The half zone inside fixes it.
- A first gizmo check failed because the sprite's right edge lay
outside the scene view (x 940, view ends at 925). The test was
wrong, the sprite now sits further left.
Tests:
- ./check.sh: 156 files, 2283 tests, CHECK EXIT 0 (before 2273).
- 10 unit tests in tests/editor/size-gizmo.test.ts.
- 6 new e2e checks in 29j: handles present, edge resize with the
opposite edge fixed, undo, knob turn, knob scale that ignores a
later sideways pull, circle radius.
- e2e 1-16,29,39-43 on the final state: ALL PASSED, 663 checks.
- Counter-checks: deciding the knob mode on every move fails the
"does not turn it" check. Not moving the centre on an edge drag
fails both edge checks. A full grip zone inside fails the new unit
test. Originals restored.
- Check total 1357 + 6 = 1363, computed. CI measured 1357 at d257298.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
import { findAssetOf, findObjectHolder } from '../../core/model/Project';
|
||
import { resolveObjects } from '../../core/model/instance';
|
||
import { findLayer, layerZ } from '../../core/model/tilemap';
|
||
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
||
import type { TilemapTool } from '../state';
|
||
import { rectOf } from '../textProbe';
|
||
import { centreOf, type HookContext } from './hookHelpers';
|
||
|
||
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
|
||
export function sceneHooks({ state, editor }: HookContext) {
|
||
const { viewport, gameView, containerEditor } = editor;
|
||
/**
|
||
* A holder's objects the way the editor sees them: the content of placed containers is resolved
|
||
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
|
||
*/
|
||
const objectsOf = (holderId: string) => {
|
||
const holder = findObjectHolder(state.project, holderId);
|
||
return holder ? resolveObjects(state.project, holder) : [];
|
||
};
|
||
return {
|
||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||
viewportCenter: () => centreOf(viewport),
|
||
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
|
||
lookAt: (x: number, y: number) => viewport.lookAt(x, y),
|
||
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
|
||
viewportRect: () => {
|
||
const p = viewport.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
|
||
},
|
||
/**
|
||
* A point in the viewport that no object covers – steps click there to deselect. A fixed
|
||
* offset from the centre lands on an object as soon as the viewport changes size.
|
||
*/
|
||
emptySpot: () => {
|
||
const p = viewport.getGlobalPosition();
|
||
const scene = state.activeScene;
|
||
const taken = scene
|
||
? objectsOf(scene.id)
|
||
.map((object) => viewport.objectRect(object.id))
|
||
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
|
||
: [];
|
||
const clear = (x: number, y: number) =>
|
||
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
|
||
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
|
||
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
|
||
}
|
||
return null;
|
||
},
|
||
viewportInfo: () => viewport.debugInfo(),
|
||
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
|
||
viewportRedraw: () => viewport.redraw(),
|
||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||
dropPreview: () => viewport.dropInfo(),
|
||
gameInfo: () => gameView.debugInfo(),
|
||
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
|
||
gizmos: () => viewport.gizmoInfo(),
|
||
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
||
polygonCorners: () => viewport.cornerInfo(),
|
||
/** The size gizmo on the selected sprite or collider, in global pixels (#223). */
|
||
sizeGizmo: () => viewport.sizeGizmoInfo(),
|
||
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
|
||
rays: () => viewport.rayInfo(),
|
||
layerOf: (id: string) => viewport.layerOf(id),
|
||
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
|
||
sceneIcon: (id: string) => viewport.iconInfo(id),
|
||
drawOrder: () => viewport.drawOrder(),
|
||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||
objectRect: (id: string) => viewport.objectRect(id),
|
||
/** What a click at a screen point picks from in the Scene tab, front to back (#203). */
|
||
sceneStack: (x: number, y: number) => viewport.stackInfo(x, y),
|
||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||
gameRect: (id: string) => gameView.objectRect(id),
|
||
gameCenter: () => centreOf(gameView),
|
||
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||
textInfo: (id: string) => viewport.textInfo(id),
|
||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||
tilemap: {
|
||
/** What the scene view painted a map with, and on which layer (#128). */
|
||
paint: () => ({ ...state.tilemapPaint }),
|
||
/** The cells of one layer as the model holds them: `"x,y"` to tile id. */
|
||
cells: (assetId: string, layerId: string) => {
|
||
const asset = findAssetOf(state.project, assetId, 'tilemap');
|
||
const layer = asset && findLayer(asset, layerId);
|
||
return layer ? { ...layer.cells } : null;
|
||
},
|
||
layers: (assetId: string) =>
|
||
findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({
|
||
id: layer.id,
|
||
name: layer.name,
|
||
hidden: layer.hidden === true,
|
||
z: layerZ(layer),
|
||
cells: Object.keys(layer.cells).length,
|
||
})) ?? null,
|
||
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
|
||
info: (objectId: string) => viewport.tilemapInfo(objectId),
|
||
gameInfo: (objectId: string) => gameView.tilemapInfo(objectId),
|
||
/** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */
|
||
solids: (objectId: string) => viewport.solidInfo(objectId),
|
||
/**
|
||
* The painting panel beside the scene view (#142): whether it is on screen, its box, the
|
||
* scene view's box next to it and its splitter – null boxes while it is hidden.
|
||
*/
|
||
panel: () => {
|
||
// The editor hides the panel itself on every other tab, so its own flag is the whole answer.
|
||
const shown = editor.tilemapPanel.visible;
|
||
return {
|
||
shown,
|
||
title: editor.tilemapPanel.title,
|
||
panel: shown ? rectOf(editor.tilemapPanel) : null,
|
||
splitter: shown ? centreOf(editor.tilemapSplitter) : null,
|
||
viewport: rectOf(viewport),
|
||
};
|
||
},
|
||
/** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */
|
||
toolAt: (tool: string) => {
|
||
const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool);
|
||
return button ? centreOf(button) : null;
|
||
},
|
||
tileButtonAt: (assetId: string) =>
|
||
currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null,
|
||
/** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */
|
||
tileSwatch: (assetId: string) => {
|
||
const palette = currentTilemapWidgets()?.palette;
|
||
const swatch = palette?.swatchGlobal(assetId);
|
||
return palette && swatch ? { ...swatch, columns: palette.columns } : null;
|
||
},
|
||
/** The plus in the layer list's header. */
|
||
addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null,
|
||
/**
|
||
* Where the painted layer's name stands in its row – a double-click there edits it (#138) –
|
||
* with the name the row shows and the field it is being edited in, while it is.
|
||
*/
|
||
layerNameAt: () => {
|
||
const widgets = currentTilemapWidgets();
|
||
const target = paintedTilemap(state);
|
||
if (!widgets || !target) return null;
|
||
const layerId = paintedLayerId(state, target.asset);
|
||
const at = widgets.layers.nameGlobal(layerId);
|
||
const editing = widgets.layers.renameField;
|
||
return at
|
||
? {
|
||
...at,
|
||
value: findLayer(target.asset, layerId)?.name ?? '',
|
||
editing: editing ? { ...centreOf(editing), value: editing.value } : null,
|
||
}
|
||
: null;
|
||
},
|
||
/** The Z field in the painted layer's row, with what it holds (#174). */
|
||
layerZAt: () => {
|
||
const widgets = currentTilemapWidgets();
|
||
const target = paintedTilemap(state);
|
||
const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset));
|
||
return field ? { ...centreOf(field), value: field.value } : null;
|
||
},
|
||
/** The duplicate mark on the painted layer's row. */
|
||
duplicateLayerAt: () => {
|
||
const widgets = currentTilemapWidgets();
|
||
const target = paintedTilemap(state);
|
||
return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null;
|
||
},
|
||
focusAt: () => {
|
||
const button = currentTilemapWidgets()?.focus;
|
||
return button ? { ...centreOf(button), label: button.text } : null;
|
||
},
|
||
/** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */
|
||
layerRowAt: (index: number) => {
|
||
const list = currentTilemapWidgets()?.layers;
|
||
const id = list?.shownIds[index];
|
||
// On the name: the middle of the row may be the Z field, which a press would scrub.
|
||
const name = id ? list.nameGlobal(id) : null;
|
||
if (!list || !id || !name) return null;
|
||
// `y` is the row's own line, so a check can tell whether it is inside the panel.
|
||
return { id, y: name.y, name, visible: list.eyeGlobal(id) };
|
||
},
|
||
/** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */
|
||
removeLayerAt: () => {
|
||
const widgets = currentTilemapWidgets();
|
||
const target = paintedTilemap(state);
|
||
if (!widgets || !target) return null;
|
||
const layerId = paintedLayerId(state, target.asset);
|
||
const at = widgets.layers.removeGlobal(layerId);
|
||
const row = widgets.layers.rowGlobal(layerId);
|
||
return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : null;
|
||
},
|
||
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
|
||
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
|
||
gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y),
|
||
},
|
||
container: {
|
||
current: () => containerEditor.currentAssetId,
|
||
center: () => centreOf(containerEditor),
|
||
rect: () => containerEditor.canvasRect(),
|
||
/** Where a template object is drawn, so a check can click or sample it. */
|
||
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
||
objectRect: (id: string) => containerEditor.objectRect(id),
|
||
dropPreview: () => containerEditor.dropInfo(),
|
||
},
|
||
};
|
||
}
|