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:
rootandClaude Opus 5 committed 2026-09-08 15:52:05 +02:00
1 parent f16801aef3
commit 4d84231b9f
115 files changed
+707 -1564

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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;
+3 -19
View File
@@ -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;
+3 -4
View File
@@ -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,
+3 -8
View File
@@ -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
View File
@@ -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,
+8 -22
View File
@@ -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 -10
View File
@@ -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');
+4 -14
View File
@@ -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);
+3 -4
View File
@@ -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();
+2 -4
View File
@@ -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: '✓',
+9 -14
View File
@@ -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 {
+9 -20
View File
@@ -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);
+1 -5
View File
@@ -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;
+2 -3
View File
@@ -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;
+3 -4
View File
@@ -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,
+3 -7
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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;
+4 -11
View File
@@ -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];
+13 -41
View File
@@ -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 });
+8 -10
View File
@@ -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();
+6 -20
View File
@@ -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
View File
@@ -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) => {