Trim needless comments and fix the picker width
Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f16801aef3
commit
4d84231b9f
115 files changed
+707
-1564
No files matched your search
+2
-2
@@ -10,8 +10,8 @@ import { EditorState } from './state';
|
||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
|
||||
/**
|
||||
* Where uploaded images live. The API writes them; the browser reads them straight off disk,
|
||||
* with no PHP in the path – they are static files whose names are their content hashes.
|
||||
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
|
||||
* static files named by their content hash.
|
||||
*/
|
||||
const TEXTURE_BASE = '/game-maker/data/textures';
|
||||
|
||||
|
||||
+3
-12
@@ -19,7 +19,6 @@ import { installTextureImport } from './textureImport';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
|
||||
|
||||
/** Tab strip order – also the one list `showTab` walks, so a new tab cannot be half-added. */
|
||||
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
@@ -38,7 +37,6 @@ export class Editor implements PlayHost {
|
||||
/** Game tab: the running world through its camera. */
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
/** Texture tab: one sheet with its frames. */
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
tab: CentreTab = 'scene';
|
||||
@@ -73,7 +71,6 @@ export class Editor implements PlayHost {
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
// A run holds a copy of the project, so it has to end before a different one is loaded.
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
@@ -91,12 +88,9 @@ export class Editor implements PlayHost {
|
||||
this.workspace = new HStack();
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
|
||||
// Dropping an image on the canvas or pasting one imports it, wherever the pointer is –
|
||||
// the same import the asset panel's "Texture" entry runs.
|
||||
installTextureImport(app.canvas, { state, textures });
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
// Centre: a tab strip above the scene view, the game view, the graph editor or the sheet editor.
|
||||
this.centre = new VStack();
|
||||
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
|
||||
tabs.minHeight = Theme.controlHeight + 8;
|
||||
@@ -144,15 +138,13 @@ export class Editor implements PlayHost {
|
||||
attachAutosave(state, store);
|
||||
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// Selecting a graph node switches to the graph, selecting a frame to the sheet: both are
|
||||
// things that exist *only* in their tab, so a selection you could not see would be a lie.
|
||||
// Selecting the behavior or the texture asset itself does not force anything.
|
||||
// A node and a frame exist only in their own tab, so selecting one switches to it;
|
||||
// selecting the behavior or texture asset itself does not.
|
||||
state.on('selection', () => {
|
||||
const kind = state.selection?.kind;
|
||||
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
});
|
||||
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
}
|
||||
|
||||
@@ -167,7 +159,6 @@ export class Editor implements PlayHost {
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
@@ -189,7 +180,7 @@ export class Editor implements PlayHost {
|
||||
this.play.tick(dt);
|
||||
}
|
||||
|
||||
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
|
||||
/** Refreshes the toolbar readout twice a second. */
|
||||
private measureFps(dt: number): void {
|
||||
this.fpsFrames += 1;
|
||||
this.fpsElapsed += dt;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Owns everything that only exists while the project is playing: the world, its systems, the
|
||||
* keyboard and the per-frame step.
|
||||
*
|
||||
* The key rule lives here: play never touches the saved model. `state.enterPlay()` swaps in a
|
||||
* runtime copy, the world is built from that copy, edits made while playing go into it, and
|
||||
* `exitPlay()` throws it away again.
|
||||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||
@@ -32,13 +29,11 @@ export interface WorldSnapshot {
|
||||
log: string[];
|
||||
}
|
||||
|
||||
/** What a play session needs from the surrounding editor, and nothing more. */
|
||||
export interface PlayHost {
|
||||
gameView: GameViewport;
|
||||
graphEditor: GraphEditor;
|
||||
/** Bring the game view to the front – play has started. */
|
||||
showGame(): void;
|
||||
/** Leave the game view if it is the one on screen – play has stopped. */
|
||||
/** Leave the game view if it is the one on screen. */
|
||||
leaveGame(): void;
|
||||
}
|
||||
|
||||
@@ -56,7 +51,6 @@ export class PlaySession {
|
||||
return this.world !== null;
|
||||
}
|
||||
|
||||
/** Live execution data for the graph editor (null when not playing). */
|
||||
traceFor(assetId: string): Trace | null {
|
||||
return this.behaviors?.traceFor(assetId) ?? null;
|
||||
}
|
||||
@@ -65,15 +59,10 @@ export class PlaySession {
|
||||
return this.world?.time ?? 0;
|
||||
}
|
||||
|
||||
/** Mirrors an edit made while playing into the running world at once. */
|
||||
syncFromModel(): void {
|
||||
this.world?.syncFromModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a run: the state switches to a runtime copy, the world is built from it and shown
|
||||
* in the Game tab. All panels stay open and edit the copy.
|
||||
*/
|
||||
play(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
@@ -97,7 +86,6 @@ export class PlaySession {
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/** Ends the run: the world and the runtime copy are discarded, the saved model returns untouched. */
|
||||
stop(): void {
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
@@ -112,7 +100,6 @@ export class PlaySession {
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
/** One frame. Does nothing when not playing. */
|
||||
tick(dt: number): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
@@ -122,20 +109,17 @@ export class PlaySession {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
// Restart game: throw the run away and start the scene again from the saved model.
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||
}
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
snapshot(): WorldSnapshot | null {
|
||||
const world = this.world;
|
||||
if (!world) return null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger
|
||||
* it. Talks only to the store and the editor state – no widgets of its own beyond the menus.
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger it.
|
||||
*/
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
@@ -10,8 +9,8 @@ import type { EditorState } from './state';
|
||||
|
||||
export class ProjectSession {
|
||||
/**
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
|
||||
* a run in progress has to end first, since it holds a copy of the project being replaced.
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
||||
* run in progress holds a copy of the project being replaced and has to end first.
|
||||
*/
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
|
||||
@@ -32,18 +32,13 @@ export function removeObjects(state: EditorState, sceneId: string, objectIds: re
|
||||
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before deleting an asset, spelling out what goes with it. Both ways of deleting (the
|
||||
* asset list's context menu and the Delete key) go through here, so the warning cannot differ
|
||||
* depending on how the user triggered it.
|
||||
*/
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
// A texture is never placed – it is worn through a property – so counting placed instances
|
||||
// would report "unused" for a picture a dozen sprites are showing. Its frames count as the
|
||||
// texture itself: a sprite wearing one of them loses its picture just the same.
|
||||
// A texture is never placed but worn through a property, so counting placed instances would
|
||||
// report "unused". Its frames count as the texture itself.
|
||||
const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId;
|
||||
const references = findPropertyUsages(state.project, ids).length;
|
||||
const detail =
|
||||
|
||||
+33
-32
@@ -1,11 +1,11 @@
|
||||
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
|
||||
import { ui, Widget } from '../ui';
|
||||
import { Button, ui, Widget } from '../ui';
|
||||
import type { CentreTab, Editor } from './Editor';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/**
|
||||
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
|
||||
* visible text and to read editor state. Read-only access; harmless in production.
|
||||
* visible text and to read editor state.
|
||||
*/
|
||||
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||||
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
|
||||
@@ -25,8 +25,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
// TypeScript does not track the assignment inside `walk`, so it believes `found` is still
|
||||
// `null` here – hence both the disable and the cast below.
|
||||
// TypeScript does not track the assignment inside `walk`, hence the disable and the cast.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!found) return null;
|
||||
const node = found as Text;
|
||||
@@ -51,13 +50,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Visible texts drawn wider than the widget that owns them – the shape of bug where a label
|
||||
* runs past its own control instead of being shortened to fit.
|
||||
*
|
||||
* The owner's box is its declared `w`/`h` at its global position, NOT `getBounds()`: a Pixi
|
||||
* container has no size of its own, so its bounds are the union of its children – including
|
||||
* the very text that overflows, which makes every overflow measure as zero. Texts inside a
|
||||
* scroll container are skipped, where sticking out is the point and the mask hides it.
|
||||
* Visible texts drawn wider than the widget that owns them. The owner's box is its declared
|
||||
* `w`/`h`, not `getBounds()`: a Pixi container's bounds are the union of its children –
|
||||
* including the overflowing text – so every overflow would measure as zero.
|
||||
*/
|
||||
const textOverflows = (all = false): { text: string; owner: string; over: number }[] => {
|
||||
const out: { text: string; owner: string; over: number }[] = [];
|
||||
@@ -65,11 +60,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
if (!node.visible) return;
|
||||
const isWidget = node instanceof Widget;
|
||||
const box = isWidget ? node : owner;
|
||||
// A mask further up – a panel clipping its contents – does not excuse a label spilling
|
||||
// out of its own control, so the question restarts at every widget. Only a mask at or
|
||||
// below the owner (a text field's scroll window, a scroll container) makes it intended.
|
||||
// `Boolean`, not `!== null`: Pixi's mask getter returns `undefined` when nothing masks,
|
||||
// so comparing against null marks every container as clipped and hides every finding.
|
||||
// A mask further up does not excuse a label spilling out of its own control, so the
|
||||
// question restarts at every widget. `Boolean`, not `!== null`: Pixi's mask getter
|
||||
// returns `undefined` when nothing masks.
|
||||
const masked = (isWidget ? false : clipped) || Boolean(node.mask);
|
||||
if (node instanceof Text) {
|
||||
if (!box || !node.text || (masked && !all)) return;
|
||||
@@ -86,6 +79,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
walk(app.stage, null, false);
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* The counterpart of `textOverflows`: buttons whose label was shortened away to nothing, which
|
||||
* leaves a control that says nothing at all. An empty `Text` is invisible to the overflow walk.
|
||||
*/
|
||||
const labelsDropped = (): { text: string; owner: string; w: number }[] => {
|
||||
const out: { text: string; owner: string; w: number }[] = [];
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof Button && node.text && !node.shownText) {
|
||||
out.push({ text: node.text, owner: node.constructor.name, w: node.w });
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
return out;
|
||||
};
|
||||
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
|
||||
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
|
||||
@@ -100,33 +109,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
viewportCenter: () => centreOf(viewport),
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
layerOf: (id: string) => viewport.layerOf(id),
|
||||
drawOrder: () => viewport.drawOrder(),
|
||||
/** Screen position of an object in the Scene tab (its drawn position through the view). */
|
||||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||||
/** The rectangle it covers on screen – for sampling colours inside it with `pixelAt`. */
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
|
||||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
// The canvas, not the whole tab: a point measured through the header would sit above
|
||||
// the drawing area a right-click test aims at.
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
pickerWidth: () => graphEditor.pickerWidth,
|
||||
},
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
/**
|
||||
* Where an image pixel of the shown sheet sits on screen. Frames are dragged in image
|
||||
* coordinates, so without this a check could only drag somewhere and hope – it could not
|
||||
* aim at a known part of the picture and then assert which part came out.
|
||||
*/
|
||||
pickerWidth: () => textureEditor.pickerWidth,
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
@@ -135,12 +141,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
/**
|
||||
* The colour actually on screen at a point, as `#rrggbb`. Everything else here reports what
|
||||
* the model *says*; this is the only way a check can assert what was really drawn – which is
|
||||
* the whole question for a texture, since a textured and an untextured sprite are identical
|
||||
* in the model apart from one property.
|
||||
*/
|
||||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||||
pixelAt: (x: number, y: number) => {
|
||||
const { pixels } = app.renderer.extract.pixels({
|
||||
target: app.stage,
|
||||
|
||||
@@ -37,13 +37,8 @@ interface PortAnchor {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph tab's canvas: the node graph of one behavior asset. Which behavior that is comes
|
||||
* from the outside (`show`), so the canvas never has an opinion about the selection – the
|
||||
* GraphEditor around it does.
|
||||
*
|
||||
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||
* the empty canvas to pan. All edits go through commands (undoable).
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
|
||||
*/
|
||||
export class GraphCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -94,19 +89,17 @@ export class GraphCanvas extends Widget {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Only the highlight of the selected node depends on the selection; which graph is shown
|
||||
// is the editor's decision.
|
||||
// Only the highlight of the selected node depends on the selection.
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('project', () => this.sync());
|
||||
state.on('mode', () => this.redraw());
|
||||
}
|
||||
|
||||
/** The behavior whose graph is shown, if any. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Shows a behavior's graph. Switching refits the view, since the next graph is a new subject. */
|
||||
/** Switching refits the view, since the next graph is a new subject. */
|
||||
show(assetId: string | null): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
@@ -115,16 +108,12 @@ export class GraphCanvas extends Widget {
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/** Recentres the graph in the view – the "reset view" the header offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's "Add node": the same palette the right-click opens, dropped in the middle of
|
||||
* the view, since a button press says nothing about where the node should go.
|
||||
*/
|
||||
/** Drops the node in the middle of the view, since a button press names no position. */
|
||||
addNodeFrom(anchor: Widget): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
||||
@@ -148,8 +137,7 @@ export class GraphCanvas extends Widget {
|
||||
private sync(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph of a thing
|
||||
// that no longer exists, so the canvas drops it rather than waiting to be told.
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph.
|
||||
this.assetId = null;
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
@@ -393,7 +381,6 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
// The header names the behavior; the hint only says what the canvas itself is up to.
|
||||
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
@@ -447,8 +434,7 @@ export class GraphCanvas extends Widget {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
|
||||
drawGrid(this.grid, {
|
||||
originX: this.pan.x,
|
||||
originY: this.pan.y,
|
||||
@@ -479,7 +465,7 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||
/** Horizontal S-curve between two points. */
|
||||
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||
|
||||
@@ -9,12 +9,8 @@ import { GraphCanvas } from './GraphCanvas';
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The Graph tab: a picker for which behavior is being worked on, the actions that act on the
|
||||
* whole graph, and the canvas the nodes are drawn on.
|
||||
*
|
||||
* It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes
|
||||
* shows that graph – but it keeps showing the last behavior when the selection moves elsewhere.
|
||||
* Otherwise clicking a sprite to check its position would empty the graph you are editing.
|
||||
* The Graph tab. It follows the selection, but keeps showing the last behavior when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the graph being edited.
|
||||
*/
|
||||
export class GraphEditor extends VStack {
|
||||
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
|
||||
@@ -38,8 +34,7 @@ export class GraphEditor extends VStack {
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
@@ -63,6 +58,11 @@ export class GraphEditor extends VStack {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedBehaviorId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
@@ -93,8 +93,7 @@ export class GraphEditor extends VStack {
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted behavior leaves the canvas empty; rather than showing nothing at all the tab
|
||||
// falls back to whatever behavior is left, the way it starts out on the first one.
|
||||
// A deleted behavior leaves the canvas empty; fall back to whatever behavior is left.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No behavior');
|
||||
|
||||
@@ -20,11 +20,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
|
||||
/**
|
||||
* Node geometry in graph units – what the node lays itself out, draws and hit-tests with.
|
||||
* Exported as the single source of these numbers: edge anchors have to land exactly on the
|
||||
* ports drawn here.
|
||||
*/
|
||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||
export const NODE_WIDTH = 176;
|
||||
export const TITLE_HEIGHT = 24;
|
||||
export const ROW_HEIGHT = 20;
|
||||
@@ -72,11 +68,7 @@ export interface PortHit {
|
||||
isOutput: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
|
||||
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
|
||||
* never interprets what a node does. Positioned in graph coordinates by the GraphCanvas.
|
||||
*/
|
||||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||||
export class NodeView extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
private readonly ports = new Graphics();
|
||||
@@ -103,7 +95,7 @@ export class NodeView extends Widget {
|
||||
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||||
}
|
||||
|
||||
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
|
||||
/** `project` resolves asset references (Spawn, Is asset). */
|
||||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
||||
this.paramLines = this.def.params.map(
|
||||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
||||
@@ -147,7 +139,6 @@ export class NodeView extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which port (if any) sits under a local point. */
|
||||
portAt(localX: number, localY: number): PortHit | null {
|
||||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||||
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
|
||||
@@ -264,8 +255,7 @@ function formatParam(
|
||||
}
|
||||
|
||||
function formatValue(value: Value): string {
|
||||
// Whole numbers stay bare to keep nodes narrow; fractions get the same precision as the
|
||||
// field that edits them, so a node never rounds harder than the inspector.
|
||||
// Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them.
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return asString(value);
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
} from './objectSection';
|
||||
|
||||
/**
|
||||
* Shows the properties of the current selection. The rows are generated from the
|
||||
* PropertySchema by one section per selection kind (asset, scene object, graph node, texture
|
||||
* frame); the
|
||||
* panel only decides when a section has to be rebuilt and when its values merely refresh.
|
||||
* Shows the properties of the current selection. The rows are generated from the PropertySchema
|
||||
* by one section per selection kind (asset, scene object, graph node, texture frame); the panel
|
||||
* only decides when a section has to be rebuilt and when its values merely refresh.
|
||||
*/
|
||||
export class Inspector extends Panel {
|
||||
private readonly scroll = new ScrollContainer();
|
||||
|
||||
@@ -29,10 +29,8 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
};
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`. Only the
|
||||
// three VariableKinds ever reach it – the loader clamps any other kind (normalizeVariables) and
|
||||
// the "+ Variable" menu offers no other – so the '?' fallback is a safety net, not a case to
|
||||
// fill in: entries for kinds a variable cannot have (asset, color, enum, key) would be dead.
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
|
||||
// three VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
|
||||
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
|
||||
@@ -26,11 +26,9 @@ import {
|
||||
} from '../../ui';
|
||||
|
||||
/**
|
||||
* What a field needs beyond its own value: the project, to resolve an asset reference (an id)
|
||||
* to a name and to list what the picker may offer.
|
||||
*
|
||||
* A getter rather than the project itself – loading a document and entering play mode replace
|
||||
* the object, and a row that outlives either must not keep answering from the old one.
|
||||
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
|
||||
* and to list what the picker may offer. A getter rather than the project itself – loading and
|
||||
* entering play mode replace that object.
|
||||
*/
|
||||
export interface FieldContext {
|
||||
project(): Project;
|
||||
@@ -103,7 +101,8 @@ export function createFieldRow(
|
||||
|
||||
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
|
||||
const addActions = (glyph: string, available: FieldActions): void => {
|
||||
const button = new Button(glyph, { variant: 'ghost', width: Theme.controlHeight });
|
||||
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
|
||||
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
|
||||
// Asked again on click: the menu must not act on a state that has moved on since the
|
||||
// last refresh (an undo between refreshes, a value edited in the viewport).
|
||||
button.onClick = () => {
|
||||
@@ -212,7 +211,7 @@ export function createField(
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while
|
||||
// the inspector stands, and the picker has to show the project as it is now.
|
||||
// the inspector stands.
|
||||
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
|
||||
label: mark(asset.id, assetChoiceLabel(asset)),
|
||||
onSelect: () => handlers.onChange(asset.id),
|
||||
@@ -231,24 +230,20 @@ export function createField(
|
||||
};
|
||||
}
|
||||
case 'texture': {
|
||||
// The id is kept here for the same reason as in the asset case, and more urgently: a
|
||||
// frame's label repeats its sheet's name, so several entries can read identically.
|
||||
// Kept for the same reason as in the asset case, and more urgently: a frame's label
|
||||
// repeats its sheet's name, so several entries can read identically.
|
||||
let current = String(value);
|
||||
const button = new Dropdown(textureRefLabel(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: textures are uploaded and frames are cut, renamed
|
||||
// and sliced away while the inspector stands, and the picker has to show what exists now.
|
||||
...textureRefChoices(ctx.project()).map((choice) => ({
|
||||
label: mark(choice.id, choice.label),
|
||||
onSelect: () => handlers.onChange(choice.id),
|
||||
})),
|
||||
];
|
||||
// Searchable: one sliced sheet alone contributes an entry per frame. The entries read
|
||||
// "Sheet / Frame", so the sheet's own name gathers its frames into one block of the
|
||||
// list and typing either name finds them.
|
||||
// Searchable: one sliced sheet alone contributes an entry per frame.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
/** The components of the rectangle, in the order they are shown. */
|
||||
const RECT_COMPONENTS = [
|
||||
{ key: 'x', label: 'X' },
|
||||
{ key: 'y', label: 'Y' },
|
||||
@@ -19,11 +18,7 @@ const RECT_COMPONENTS = [
|
||||
{ key: 'h', label: 'Height' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Structure-defining facts of a frame selection: none. A frame is a name and four numbers
|
||||
* whatever texture it was cut from, so the rows never differ – only the frame disappearing
|
||||
* can force a rebuild, and that is what `refreshFrameSection` reports.
|
||||
*/
|
||||
/** Structure-defining facts of a frame selection: none – only the frame disappearing rebuilds. */
|
||||
export function frameSignature(project: Project, assetId: string, frameId: string): string {
|
||||
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
|
||||
}
|
||||
@@ -37,8 +32,8 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
|
||||
addTitle(host, 'TEXTURE FRAME');
|
||||
host.nameField = new TextField(frame.name, {
|
||||
onChange: (name) => {
|
||||
// Like an asset name. The command rejects a blank name by itself; what it cannot do is
|
||||
// put the old text back into the field, which is why an empty one is not sent at all.
|
||||
// The command rejects a blank name but cannot put the old text back into the field,
|
||||
// so an empty one is not sent at all.
|
||||
if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim()));
|
||||
else host.nameField?.setValue(frame.name);
|
||||
},
|
||||
@@ -81,8 +76,7 @@ function rectRow(
|
||||
): void {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
// `decimals: 0` is honest here and only here in the inspector: `normalizeFrameRect` rounds
|
||||
// every value that reaches the model, so the field never shortens a number it is holding.
|
||||
// `decimals: 0` is safe here: `normalizeFrameRect` rounds every value that reaches the model.
|
||||
{ key, label, kind: 'number', default: 0, step: 1, decimals: 0 },
|
||||
value,
|
||||
rectHandlers(host, assetId, frameId, key),
|
||||
@@ -93,13 +87,9 @@ function rectRow(
|
||||
|
||||
/**
|
||||
* The whole rectangle with one component replaced; the other three are read from the model at
|
||||
* that moment, so a frame dragged in the Texture tab in between is not undone by typing into a
|
||||
* field beside it.
|
||||
*
|
||||
* Normalising here as well as in the command is not a duplicate rule but the same one applied
|
||||
* to the *preview*: an un-normalised preview would draw the frame off the sheet on the way
|
||||
* through a scrub, and a half-typed "1" of "16" would jump it to the corner. Because it is the
|
||||
* same function the command uses, what is previewed is exactly what is committed.
|
||||
* that moment, so a frame dragged in the Texture tab in between is not undone. Normalising the
|
||||
* preview with the same function the command uses keeps it on the sheet and identical to what
|
||||
* is committed.
|
||||
*/
|
||||
function rectHandlers(
|
||||
host: InspectorHost,
|
||||
@@ -124,9 +114,8 @@ function rectHandlers(
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame is looked up inside the selected texture rather than by its id alone: the
|
||||
* selection names both, and honouring both keeps a selection left over from a deleted texture
|
||||
* from editing whichever frame later inherits the id.
|
||||
* Looked up inside the selected texture rather than by frame id alone, so a selection left over
|
||||
* from a deleted texture cannot edit whichever frame later inherits the id.
|
||||
*/
|
||||
function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined {
|
||||
return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId);
|
||||
|
||||
@@ -2,11 +2,7 @@ import { Label, type TextField, type VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import type { FieldContext, FieldRow } from './fieldFactory';
|
||||
|
||||
/**
|
||||
* What the inspector sections build into: the panel body and the registry of rows. It is also
|
||||
* the FieldContext the sections hand to the field factory, so a row can resolve an asset
|
||||
* reference without the factory knowing anything about the editor state.
|
||||
*/
|
||||
/** What the inspector sections build into: the panel body and the registry of rows. */
|
||||
export interface InspectorHost extends FieldContext {
|
||||
readonly state: EditorState;
|
||||
readonly body: VStack;
|
||||
|
||||
@@ -88,9 +88,8 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable params are the one kind of setting the node cannot declare on its own: their
|
||||
* options are the variables of the behavior the node sits in. Everything else, asset
|
||||
* references included, is used exactly as declared.
|
||||
* Variable params are the one setting a node cannot declare on its own: their options are the
|
||||
* variables of the behavior it sits in. Everything else is used exactly as declared.
|
||||
*/
|
||||
function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
|
||||
if (param.kind !== 'enum' || param.key !== 'variable' || asset.type !== 'behavior') return param;
|
||||
|
||||
@@ -287,10 +287,9 @@ function componentRow<V extends object>(
|
||||
* or, inside a container, to the template) and ↑ (apply upwards).
|
||||
*
|
||||
* Inside a container a value has two bases – the template it was instantiated from and, one
|
||||
* step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs
|
||||
* from are listed, so a menu never contains an entry that would change nothing. Dropping the
|
||||
* override is what makes the object follow the asset again: the template is only read when
|
||||
* the container is placed, never at draw time.
|
||||
* step further up, the asset – so ↺/↑ can become a menu. Dropping the override is what makes
|
||||
* the object follow the asset again: the template is only read when the container is placed,
|
||||
* never at draw time.
|
||||
*/
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
|
||||
@@ -19,13 +19,9 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
|
||||
/**
|
||||
* Row handlers that preview into the model while editing and take that preview back before
|
||||
* committing.
|
||||
*
|
||||
* Scrubbing has to be visible at once, so the value goes straight into the model and is only
|
||||
* redrawn (`preview`), without an undo entry. A command records the value it finds at execute
|
||||
* time, so committing right away would make the last previewed frame the undo target: the
|
||||
* value from before the preview is written back first – including "the key did not exist",
|
||||
* via `remove` – and only then is the command executed.
|
||||
* committing. A command records the value it finds at execute time, so committing right away
|
||||
* would make the last previewed frame the undo target: the value from before the preview is
|
||||
* written back first – including "the key did not exist", via `remove`.
|
||||
*/
|
||||
export function previewingHandlers<Target, Model = PropertyValue>(
|
||||
host: InspectorHost,
|
||||
|
||||
@@ -73,7 +73,7 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/**
|
||||
* A texture is an uploaded picture, so its menu entry opens the file dialog instead of
|
||||
* creating an empty asset – a texture without an image would draw nothing.
|
||||
* creating an empty asset.
|
||||
*/
|
||||
private importTextures(): void {
|
||||
if (!this.textures) {
|
||||
|
||||
+2
-6
@@ -153,9 +153,8 @@ export class EditorState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one history operation. A `CommandError` is the documented way for a command to
|
||||
* reject an edit, so it becomes a notice; anything else is a real defect and keeps
|
||||
* travelling. Undo and redo go through here too – they can reject just like `execute`.
|
||||
* Runs one history operation. A `CommandError` is a command rejecting the edit, so it becomes
|
||||
* a notice; anything else is a real defect and keeps travelling.
|
||||
*/
|
||||
private runEdit(operation: () => boolean): boolean {
|
||||
try {
|
||||
@@ -196,7 +195,6 @@ export class EditorState {
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
|
||||
// The type says `objectIds` is always there – untyped callers are exactly why this guard exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'object' && !selection.objectIds)
|
||||
selection = objectSelection(selection.sceneId, [selection.objectId]);
|
||||
@@ -260,8 +258,6 @@ export class EditorState {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'node') return s.assetId;
|
||||
// A frame names a texture and can never lead to a behavior. Answered here rather than
|
||||
// fallen through, so what remains below is the asset/object pair the lookup assumes.
|
||||
if (s.kind === 'frame') return null;
|
||||
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
|
||||
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
|
||||
|
||||
@@ -2,18 +2,13 @@ import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
|
||||
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
|
||||
|
||||
/** Width of the label column, matching the inspector's so the two read as one language. */
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
/**
|
||||
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
|
||||
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
|
||||
* cannot disagree with it.
|
||||
*
|
||||
* The dialog states, live, how many frames the current numbers would produce and how big each
|
||||
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
|
||||
* you find out by undoing into something you see before committing.
|
||||
* Cuts a sheet into a grid, asked for as columns and rows rather than a cell size: the counts are
|
||||
* what the user knows, and a cell size derived from the picture cannot disagree with them.
|
||||
*/
|
||||
export class SliceDialog extends Popup {
|
||||
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
|
||||
@@ -60,7 +55,6 @@ export class SliceDialog extends Popup {
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
|
||||
private pair(
|
||||
labelA: string,
|
||||
keyA: keyof SliceOptions,
|
||||
@@ -90,7 +84,7 @@ export class SliceDialog extends Popup {
|
||||
const field = new NumberField(this.options[key], {
|
||||
min,
|
||||
step: 1,
|
||||
// Counts of cells and pixel offsets; the value is rounded on change, so 0 hides nothing.
|
||||
// Counts and pixel offsets, rounded on change, so 0 decimals hides nothing.
|
||||
decimals: 0,
|
||||
onChange: (value) => {
|
||||
this.options[key] = Math.round(Number(value));
|
||||
@@ -101,7 +95,6 @@ export class SliceDialog extends Popup {
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Live readout of what the current numbers would do, including the refusal case. */
|
||||
private refreshSummary(): void {
|
||||
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
|
||||
const first = rects[0];
|
||||
|
||||
@@ -43,7 +43,6 @@ const MIN_LABEL_SIZE = 28;
|
||||
const LABEL_INSET = 3;
|
||||
const LABEL_PAD = 2;
|
||||
|
||||
/** A drag of an existing frame: which one, from what, and by which handle. */
|
||||
interface FrameDrag {
|
||||
frameId: string;
|
||||
handle: FrameHandle;
|
||||
@@ -52,7 +51,7 @@ interface FrameDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */
|
||||
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
|
||||
interface NewFrame {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
@@ -60,15 +59,8 @@ interface NewFrame {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames
|
||||
* are created by dragging on empty space and moved or resized by their handles.
|
||||
*
|
||||
* Everything but the picture is drawn in widget-local pixels rather than in a zoomed
|
||||
* container: outlines, handles and names have to keep a constant size on screen, which a
|
||||
* scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so
|
||||
* the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a
|
||||
* pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview
|
||||
* would show.
|
||||
* The Texture tab's canvas. Everything but the picture is drawn in widget-local pixels rather
|
||||
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
|
||||
*/
|
||||
export class TextureCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -127,7 +119,7 @@ export class TextureCanvas extends Widget {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
/** Which texture is shown. Switching resets the view, since the next picture is a new subject. */
|
||||
/** Switching resets the view, since the next picture is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
@@ -139,17 +131,12 @@ export class TextureCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Refits the picture into the widget – the "reset view" the toolbar offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an image pixel currently sits on screen. Only the check scripts need this: dragging
|
||||
* a frame is done in image coordinates, so a test that cannot convert them can drag
|
||||
* somewhere but cannot aim at a known part of the picture.
|
||||
*/
|
||||
/** Where an image pixel sits on screen; only the check scripts need it, to aim drags. */
|
||||
imageToScreen(x: number, y: number): Vec2 {
|
||||
const local = imageToLocal(this.view, x, y);
|
||||
const origin = this.getGlobalPosition();
|
||||
@@ -176,9 +163,8 @@ export class TextureCanvas extends Widget {
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const asset = this.asset();
|
||||
// Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds
|
||||
// to hold such a frame inside, so it would keep a rectangle that lies off the sheet for
|
||||
// good. An unmeasured texture is a failed upload, and the hint says so.
|
||||
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
|
||||
// that lies off the sheet.
|
||||
if (!asset || asset.width <= 0 || asset.height <= 0) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
@@ -191,8 +177,7 @@ export class TextureCanvas extends Widget {
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
|
||||
// The selected frame is tested first and by its handles: it is the one being worked on,
|
||||
// and its handles must stay reachable even where a frame drawn later overlaps them.
|
||||
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||
if (selected && onSelected) {
|
||||
@@ -309,7 +294,6 @@ export class TextureCanvas extends Widget {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** Announces what a press would do before it happens (resize arrows, move, crosshair). */
|
||||
private updateCursor(local: Vec2): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
@@ -345,7 +329,6 @@ export class TextureCanvas extends Widget {
|
||||
if (asset && texture) {
|
||||
this.image.texture = texture;
|
||||
this.image.position.set(this.view.panX, this.view.panY);
|
||||
// The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale.
|
||||
this.image.width = asset.width * this.view.zoom;
|
||||
this.image.height = asset.height * this.view.zoom;
|
||||
}
|
||||
@@ -365,11 +348,7 @@ export class TextureCanvas extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The checkerboard behind the picture. It is clipped to the picture rather than filling the
|
||||
* widget: its job is to make transparent pixels of *this image* visible, and a board that
|
||||
* ran past the edges would hide where the image actually ends.
|
||||
*/
|
||||
/** Clipped to the picture rather than filling the widget, which would hide where it ends. */
|
||||
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
|
||||
this.checker.clear();
|
||||
if (!asset || !hasPicture || asset.width <= 0) return;
|
||||
@@ -377,8 +356,8 @@ export class TextureCanvas extends Widget {
|
||||
const w = asset.width * this.view.zoom;
|
||||
const h = asset.height * this.view.zoom;
|
||||
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
|
||||
// Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a
|
||||
// high zoom would otherwise cost tens of thousands of rectangles per redraw.
|
||||
// Only the squares inside the widget: a 4000 px sheet zoomed in would otherwise cost tens of
|
||||
// thousands of rectangles per redraw.
|
||||
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
|
||||
const x0 = from(origin.x, 0);
|
||||
const y0 = from(origin.y, 0);
|
||||
@@ -467,15 +446,8 @@ export class TextureCanvas extends Widget {
|
||||
const x = at.x + LABEL_INSET;
|
||||
const y = at.y + LABEL_INSET;
|
||||
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
|
||||
/*
|
||||
* A dark plate behind the name, not just a colour choice for the text.
|
||||
*
|
||||
* The name is drawn *on the user's picture*, which may be any colour – the grey used at
|
||||
* first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on
|
||||
* an arbitrary image. The plate makes the background known, so light text on it always
|
||||
* reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than
|
||||
* in a theme colour so that it survives whatever the scene puts behind it.
|
||||
*/
|
||||
// A dark plate, not just a text colour: the name is drawn on the user's picture, where no
|
||||
// fixed colour is legible against every background.
|
||||
this.overlay
|
||||
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
|
||||
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
|
||||
|
||||
@@ -11,13 +11,8 @@ import { TextureCanvas } from './TextureCanvas';
|
||||
const INFO_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* The Texture tab: a picker for which texture is being worked on, the tools that act on the
|
||||
* whole sheet, and the canvas the frames are drawn on.
|
||||
*
|
||||
* It follows the selection the way the graph editor does – selecting a texture asset (or one
|
||||
* of its frames) shows it here – but it does not *force* the tab, and it keeps showing the
|
||||
* last texture when the selection moves elsewhere. Otherwise clicking a sprite to check
|
||||
* whether it wears the right frame would empty the tab you are working in.
|
||||
* The Texture tab. It follows the selection, but keeps showing the last texture when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class TextureEditor extends VStack {
|
||||
private readonly canvas: TextureCanvas;
|
||||
@@ -40,8 +35,7 @@ export class TextureEditor extends VStack {
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
@@ -63,7 +57,11 @@ export class TextureEditor extends VStack {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** Shows a texture explicitly (opening the tab for a freshly imported image). */
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* How the Texture tab maps image pixels onto the widget, and what a drag on a frame means.
|
||||
* Pure geometry – no Pixi, no editor state – so the fiddly part of the editor (which handle
|
||||
* did I grab, what does dragging it do) is unit-testable instead of only clickable.
|
||||
*/
|
||||
/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */
|
||||
import type { FrameRect } from '../../core/model/texture';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
|
||||
@@ -27,7 +23,6 @@ export function localToImage(view: SheetView, x: number, y: number): Vec2 {
|
||||
return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom };
|
||||
}
|
||||
|
||||
/** The view that centres the whole picture in a `w`×`h` widget. */
|
||||
export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView {
|
||||
if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 };
|
||||
const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1);
|
||||
@@ -50,10 +45,7 @@ export function zoomAbout(view: SheetView, factor: number, localX: number, local
|
||||
return after;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which part of a frame a point grabs: a corner or edge handle (`hx`/`hy` in −1/0/1, naming
|
||||
* the edge each axis is pinned to), `'move'` anywhere else inside it, or null outside.
|
||||
*/
|
||||
/** `hx`/`hy` are −1/0/1, naming the edge each axis is pinned to; `'move'` is anywhere inside. */
|
||||
export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move';
|
||||
|
||||
/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */
|
||||
@@ -81,12 +73,8 @@ export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press at `point` (image pixels) does to `rect`. `tolerance` is the grab radius in
|
||||
* image pixels – the caller converts a constant screen distance, so handles stay equally easy
|
||||
* to hit at every zoom.
|
||||
*
|
||||
* Corners are tested before edges so that at a small frame, where all handles overlap, the
|
||||
* corner wins: a corner resizes both axes and is the one worth reaching for.
|
||||
* What a press at `point` does to `rect`; `tolerance` is the grab radius in image pixels.
|
||||
* Corners are tested before edges, so the corner wins on a frame small enough for both to overlap.
|
||||
*/
|
||||
export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null {
|
||||
for (const handle of HANDLES) {
|
||||
@@ -99,9 +87,8 @@ export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): Frame
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle a drag produces: `handle` decides which edges follow the pointer. The result
|
||||
* may be inverted or stick out of the image – squaring it up is `normalizeFrameRect`'s job,
|
||||
* so that a drag and a typed-in value are corrected by exactly one piece of code.
|
||||
* The rectangle a drag produces. It may come out inverted or outside the image: squaring it up
|
||||
* is `normalizeFrameRect`'s job, shared with typed-in values.
|
||||
*/
|
||||
export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect {
|
||||
if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy };
|
||||
@@ -126,7 +113,6 @@ export function rectBetween(a: Vec2, b: Vec2): FrameRect {
|
||||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
||||
}
|
||||
|
||||
/** The mouse cursor a handle asks for, so a resize edge announces itself before it is grabbed. */
|
||||
export function handleCursor(handle: FrameHandle): string {
|
||||
if (handle === 'move') return 'move';
|
||||
if (handle.hx === 0) return 'ns-resize';
|
||||
|
||||
+16
-56
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
|
||||
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
|
||||
* share every step after the file – so a dropped and a picked image behave identically.
|
||||
*/
|
||||
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { TextureAsset } from '../core/model/Asset';
|
||||
@@ -12,16 +7,10 @@ import type { EditorState } from './state';
|
||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
/**
|
||||
* Longest side a stored texture may have. A 4000×3000 phone photo has no business being a
|
||||
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
|
||||
*/
|
||||
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
||||
export const MAX_TEXTURE_SIDE = 2048;
|
||||
|
||||
/**
|
||||
* Byte budget for one upload. The API would take 4 MB, but nginx cuts every request off at
|
||||
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
|
||||
*/
|
||||
/** Byte budget for one upload: nginx cuts every request off at 1 MB, below the API's own 4 MB. */
|
||||
export const MAX_UPLOAD_BYTES = 950 * 1024;
|
||||
|
||||
/** Tried in order when an image has to be re-encoded; the first result that fits wins. */
|
||||
@@ -30,9 +19,8 @@ const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
|
||||
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
|
||||
|
||||
/**
|
||||
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
|
||||
* pixel art into mush – so anything that is already small enough in both senses is sent byte
|
||||
* for byte as it lies on disk. `bytes` is the encoded file size, not the pixel count.
|
||||
* Re-encoding would turn crisp pixel art into mush, so an image within both limits is kept byte
|
||||
* for byte. `bytes` is the encoded file size, not the pixel count.
|
||||
*/
|
||||
export function planImageFit(width: number, height: number, bytes: number): ImagePlan {
|
||||
const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height));
|
||||
@@ -44,11 +32,7 @@ export function planImageFit(width: number, height: number, bytes: number): Imag
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes at decreasing quality and stops at the first result within `MAX_UPLOAD_BYTES`. When
|
||||
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
|
||||
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
|
||||
*/
|
||||
/** Encodes at decreasing quality; when none fits the budget, the last attempt comes back anyway. */
|
||||
export async function encodeWithinBudget(encode: (quality: number) => Promise<Blob>): Promise<Blob> {
|
||||
let result = await encode(WEBP_QUALITIES[0]);
|
||||
for (const quality of WEBP_QUALITIES.slice(1)) {
|
||||
@@ -66,11 +50,7 @@ export interface PreparedImage {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes to upload for one picked file: the file itself whenever it already fits (see
|
||||
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
|
||||
* or stays too large even at the lowest quality.
|
||||
*/
|
||||
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
@@ -81,7 +61,6 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
try {
|
||||
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
|
||||
if (plan.kind === 'keep') {
|
||||
// Untouched on purpose: re-encoding costs quality and would break pixel art.
|
||||
return {
|
||||
blob: file,
|
||||
type: file.type || 'application/octet-stream',
|
||||
@@ -89,8 +68,7 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
height: bitmap.height,
|
||||
};
|
||||
}
|
||||
// Only the first frame of an animated GIF survives this, which is all the renderer ever
|
||||
// draws – a texture is one still picture.
|
||||
// Only the first frame of an animated GIF survives this.
|
||||
const canvas = new OffscreenCanvas(plan.width, plan.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('This browser cannot resize images.');
|
||||
@@ -122,10 +100,8 @@ export interface ImportOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the files one after another – prepare, upload, create the asset – selects the last
|
||||
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
|
||||
* named against the project as it stands *after* the previous one. A file that fails is
|
||||
* reported and the remaining ones are still imported.
|
||||
* Imports the files sequentially, so each asset is named against the project as the previous one
|
||||
* left it. A file that fails is reported in the notice; the remaining ones are still imported.
|
||||
*/
|
||||
export async function importImages(
|
||||
files: readonly File[],
|
||||
@@ -147,11 +123,8 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
const image = await prepareImage(file);
|
||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
||||
|
||||
// The server stores by content hash, so importing a picture the project already has gives
|
||||
// back the file it already references. Making a second asset for it would be clutter with a
|
||||
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
|
||||
// belongs to the single shared source. Reuse instead – and say so, rather than silently
|
||||
// doing nothing visible.
|
||||
// The server stores by content hash, so a re-imported picture comes back as the file the
|
||||
// project already references; two assets on one file would disagree about `filter`.
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file,
|
||||
);
|
||||
@@ -169,11 +142,7 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the whole batch: how many arrived, how many were already there, and why the
|
||||
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
|
||||
* picture the project already had, and the user would look for a new asset that never appeared.
|
||||
*/
|
||||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||||
export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||||
const reused = outcomes.filter((outcome) => outcome.reused).length;
|
||||
@@ -195,7 +164,6 @@ export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||||
}
|
||||
|
||||
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
|
||||
function assetNameFor(file: File): string {
|
||||
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
|
||||
}
|
||||
@@ -206,10 +174,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
// --- ways in ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the browser's file dialog and resolves with what the user chose – empty when the
|
||||
* dialog was cancelled. The `<input>` lives only for this one call.
|
||||
*/
|
||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||
export function pickImageFiles(): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
@@ -223,13 +188,12 @@ export function pickImageFiles(): Promise<File[]> {
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog would leave both the element and this promise behind.
|
||||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
@@ -237,12 +201,8 @@ export function pickAndImport(deps: TextureImportDeps): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
|
||||
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
|
||||
* again.
|
||||
*
|
||||
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
|
||||
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
|
||||
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
|
||||
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
|
||||
*/
|
||||
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
|
||||
Reference in new issue
Block a user