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
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* The playing view: the running world seen through the scene's camera. The camera's frame
|
||||
* defines what is visible and is the only thing drawn – it is letterboxed into the widget and
|
||||
* everything outside it is masked away. Without a world or without a camera nothing renders.
|
||||
*
|
||||
* Nothing here edits: the view reports the pointer to the world (so graphs can use Mouse
|
||||
* position, Is hovered and On click) and otherwise only draws.
|
||||
*/
|
||||
/** The running world seen through the scene's camera, letterboxed into the widget. */
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
@@ -17,7 +10,6 @@ import { type Frame, WorldViewport } from './WorldViewport';
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
/** Shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
private playWorld: World | null = null;
|
||||
|
||||
@@ -37,7 +29,7 @@ export class GameViewport extends WorldViewport {
|
||||
this.on('pointerupoutside', () => this.playWorld?.setPointerDown(false));
|
||||
}
|
||||
|
||||
/** The world to draw (null = not playing). */
|
||||
/** null = not playing. */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
@@ -62,10 +54,7 @@ export class GameViewport extends WorldViewport {
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the mouse position to the running world in world units. Outside the camera frame
|
||||
* counts as "not in view" – that region is letterbox, not game.
|
||||
*/
|
||||
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* The editable view of a scene: the model drawn with a grid, selection outlines, drag-to-move
|
||||
* (all selected objects at once), rubber-band selection on empty space, Ctrl-click to add or
|
||||
* remove, middle-drag or Shift-drag panning and wheel zoom.
|
||||
*
|
||||
* It shows `state.project` – in play mode that is the runtime copy, so the live positions are
|
||||
* visible and stay fully editable.
|
||||
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
|
||||
* copy, so live positions are visible and stay editable.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
@@ -20,9 +16,8 @@ import { dashedLine } from './symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
/** Pixels the pointer must travel before a press on an object starts moving it. */
|
||||
const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
@@ -30,7 +25,6 @@ const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/** A drag of the selected objects: every moved root with the local position it started from. */
|
||||
interface ObjectDrag {
|
||||
sceneId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
@@ -38,7 +32,7 @@ interface ObjectDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress (viewport-local pixels). */
|
||||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
@@ -47,7 +41,6 @@ interface Marquee {
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
private readonly grid = new Graphics();
|
||||
/** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
@@ -77,7 +70,7 @@ export class SceneViewport extends WorldViewport {
|
||||
});
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the view (used to place assets). */
|
||||
/** World coordinates at the centre of the view. */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
@@ -96,7 +89,7 @@ export class SceneViewport extends WorldViewport {
|
||||
return Theme.colors.viewport;
|
||||
}
|
||||
|
||||
/** Without a scene there is nothing to frame – the neutral base colour stands alone. */
|
||||
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.state.activeScene);
|
||||
}
|
||||
@@ -114,7 +107,7 @@ export class SceneViewport extends WorldViewport {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && e.target === this) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
|
||||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = {
|
||||
start: { x: local.x, y: local.y },
|
||||
@@ -213,7 +206,7 @@ export class SceneViewport extends WorldViewport {
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(scene.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
|
||||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
@@ -222,7 +215,7 @@ export class SceneViewport extends WorldViewport {
|
||||
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn position lies inside it. */
|
||||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
@@ -277,9 +270,8 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol (behavior, camera) to its parent. Sprites show
|
||||
* their nesting in the hierarchy panel instead, to keep the scene uncluttered; colliders sit
|
||||
* on their body anyway, so they get no line either.
|
||||
* Dashed line from every parented symbol to its parent. Sprites show their nesting in the
|
||||
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
|
||||
*/
|
||||
private drawParentLinks(items: DrawItem[], scale: number): void {
|
||||
this.links.clear();
|
||||
@@ -295,7 +287,6 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name label next to a symbol. */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
@@ -316,12 +307,11 @@ export class SceneViewport extends WorldViewport {
|
||||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||
}
|
||||
|
||||
/** Selection outlines (the primary object gets the corner handles) and the rubber band. */
|
||||
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
|
||||
private drawGizmos(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
|
||||
@@ -4,21 +4,15 @@ import type { TextureRef } from '../core/model/texture';
|
||||
const DEFAULT_BASE = '/game-maker/data/textures';
|
||||
|
||||
/**
|
||||
* Keeps uploaded images on the GPU, keyed by their file name.
|
||||
*
|
||||
* Nothing is ever evicted, and that is safe rather than sloppy: file names are content
|
||||
* addressed, so a name cannot come to mean different bytes and a cached texture cannot go
|
||||
* stale. It also means the same picture used by a hundred sprites is uploaded once.
|
||||
*
|
||||
* A miss is not an error – it starts the load and reports "not yet", so the caller draws its
|
||||
* plain colour in the meantime and gets a redraw when the picture arrives. That is why a
|
||||
* sprite never flickers and why nothing here needs to be awaited.
|
||||
* Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are
|
||||
* content addressed, so a cached texture can never go stale. A miss is not an error – it starts
|
||||
* the load and reports "not yet", so callers never await and get a redraw when the picture lands.
|
||||
*/
|
||||
export class TextureLibrary {
|
||||
private base = DEFAULT_BASE;
|
||||
private readonly loaded = new Map<string, Texture>();
|
||||
private readonly loading = new Set<string>();
|
||||
/** Files whose load failed; retried never, so one broken reference cannot spam the network. */
|
||||
/** Never retried, so one broken reference cannot spam the network. */
|
||||
private readonly failed = new Set<string>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
@@ -28,12 +22,9 @@ export class TextureLibrary {
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture if it is already in memory, otherwise null while it loads.
|
||||
*
|
||||
* Caveat of content addressing: two texture *assets* can name the same file (the same image
|
||||
* uploaded twice) while disagreeing about `smooth`. The scale mode belongs to the shared
|
||||
* source, so the last one drawn wins – deterministically, since draw order is stable. The
|
||||
* upload avoids creating that situation by reusing an existing asset for a known file.
|
||||
* The picture if it is in memory, otherwise null while it loads. The scale mode belongs to the
|
||||
* shared source, so two assets naming the same file with different `smooth` fight over it and
|
||||
* the last one drawn wins; upload avoids that by reusing the existing asset for a known file.
|
||||
*/
|
||||
get(ref: TextureRef): Texture | null {
|
||||
const texture = this.loaded.get(ref.file);
|
||||
@@ -44,16 +35,14 @@ export class TextureLibrary {
|
||||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||||
if (texture.source.scaleMode !== mode) {
|
||||
texture.source.scaleMode = mode;
|
||||
// Assigning the mode alone is not enough, and the difference is invisible until a reload:
|
||||
// Pixi's `scaleMode` setter only writes mag/min/mipmapFilter and leaves the style's cached
|
||||
// `_sharedResourceId` in place – but that id is the key the GPU sampler is looked up under,
|
||||
// so the old sampler keeps being used. `update()` drops the id and announces the change.
|
||||
// Pixi's `scaleMode` setter leaves the style's cached `_sharedResourceId` stale, so the old
|
||||
// GPU sampler keeps being used until a reload; `update()` drops the id.
|
||||
texture.source.style.update();
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a picture has arrived. Returns the unsubscribe. */
|
||||
/** Runs `fn` whenever a picture has arrived; returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
@@ -77,8 +66,4 @@ export class TextureLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one library. A second instance would only duplicate GPU uploads – the cache is keyed by
|
||||
* content, so there is nothing an instance could hold that another should not share.
|
||||
*/
|
||||
export const textureLibrary = new TextureLibrary();
|
||||
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* What the scene view and the game view have in common: turning a list of `DrawItem`s into
|
||||
* Pixi display objects, keeping them in z order, culling what is off screen and clipping the
|
||||
* whole thing to the view rectangle.
|
||||
*
|
||||
* The two differ in *where* the items come from and in what may be done with them, not in how
|
||||
* they are drawn – so the differences are the abstract members below, not `if` branches.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
@@ -18,7 +10,6 @@ import { drawItem } from './symbols';
|
||||
import { textureLibrary } from './TextureLibrary';
|
||||
import { type View, worldBounds, worldToScreen } from './view';
|
||||
|
||||
/** What a redraw draws: the items and the colour behind them. */
|
||||
export interface Frame {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
@@ -26,22 +17,17 @@ export interface Frame {
|
||||
|
||||
export abstract class WorldViewport extends Widget {
|
||||
protected readonly bg = new Graphics();
|
||||
/** Everything that may extend past the viewport is clipped through this container. */
|
||||
protected readonly content = new Container();
|
||||
protected readonly world = new Container();
|
||||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||
/** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */
|
||||
protected readonly objects = new Container();
|
||||
/**
|
||||
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can
|
||||
* never hide them, no matter what z-order the scene ends up with.
|
||||
*/
|
||||
/** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */
|
||||
protected readonly overlay = new Container();
|
||||
protected readonly contentMask = new Graphics();
|
||||
protected readonly nodes = new Map<string, Graphics>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private visibleCount = 0;
|
||||
/** Items of the last redraw (for hit tests such as the rubber band). */
|
||||
protected lastItems: DrawItem[] = [];
|
||||
|
||||
protected constructor(protected readonly state: EditorState) {
|
||||
@@ -55,12 +41,10 @@ export abstract class WorldViewport extends Widget {
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('mode', () => this.redraw());
|
||||
// Every tick moves things: redraw the live view when it is on screen.
|
||||
state.on('runtime', () => {
|
||||
if (this.visible) this.redraw();
|
||||
});
|
||||
// A picture that finished loading changes what is on screen without anything in the model
|
||||
// having moved. Unconditional: this fires once per image, not per frame.
|
||||
// Unconditional: a picture arriving fires once per image, not per frame.
|
||||
textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
@@ -70,32 +54,26 @@ export abstract class WorldViewport extends Widget {
|
||||
|
||||
// --- what the two views differ in ------------------------------------------------------------
|
||||
|
||||
/** The active mapping, or null when there is nothing to show. */
|
||||
protected abstract currentView(): View | null;
|
||||
|
||||
/** The items to draw and the colour behind them. Only called when `currentView()` is set. */
|
||||
/** Only called when `currentView()` is non-null. */
|
||||
protected abstract collectFrame(): Frame;
|
||||
|
||||
/** Colour of the whole widget, behind and around the view rectangle. */
|
||||
/** Fills the whole widget, behind and around the view rectangle. */
|
||||
protected abstract get baseColor(): number;
|
||||
|
||||
/** Whether the view rectangle is filled with the frame's background colour. */
|
||||
protected paintsBackground(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether item nodes take pointer events (only the editable view lets objects be grabbed). */
|
||||
protected get itemsInteractive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runs before the items are drawn, with the view (null = nothing to show). */
|
||||
protected beforeItems(_view: View | null): void {}
|
||||
|
||||
/** Runs after the items are drawn – decorations that belong on top. */
|
||||
protected afterItems(_items: DrawItem[], _view: View): void {}
|
||||
|
||||
/** Per item, every redraw: anything drawn beside the item itself (labels). */
|
||||
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
|
||||
|
||||
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
||||
@@ -105,24 +83,22 @@ export abstract class WorldViewport extends Widget {
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** Whether anything is rendered and how many items are on screen. */
|
||||
debugInfo(): { rendering: boolean; visibleItems: number } {
|
||||
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
||||
}
|
||||
|
||||
/** Which layer an item is drawn in: symbols live in `overlay`, above all `objects`. */
|
||||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||||
const node = this.nodes.get(objectId);
|
||||
if (!node) return null;
|
||||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||||
}
|
||||
|
||||
/** Object ids in the order they are drawn, back to front. */
|
||||
/** Ids in the order they are drawn, back to front. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
/** Where an object is drawn, in global screen coordinates. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
@@ -132,11 +108,7 @@ export abstract class WorldViewport extends Widget {
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen rectangle an object occupies: its footprint through the current view. Checks
|
||||
* sample colours inside it to assert what was actually drawn, which `objectScreen` alone
|
||||
* cannot support because it reports a point.
|
||||
*/
|
||||
/** The screen rectangle an object occupies, so tests can sample the pixels inside it. */
|
||||
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
@@ -176,13 +148,12 @@ export abstract class WorldViewport extends Widget {
|
||||
this.world.scale.set(view.scale);
|
||||
const bounds = worldBounds(view);
|
||||
|
||||
// Sync display objects with items; anything outside the view is neither drawn nor hit-testable.
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
seen.add(item.id);
|
||||
const size = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
||||
// selected and switched back on from the hierarchy panel.
|
||||
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
@@ -215,9 +186,8 @@ export abstract class WorldViewport extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
|
||||
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
|
||||
* deterministic and hierarchy changes are honoured too.
|
||||
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order. Explicit rather than
|
||||
* Pixi's lazy `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
||||
*/
|
||||
private syncDrawOrder(items: DrawItem[]): void {
|
||||
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
||||
@@ -225,17 +195,13 @@ export abstract class WorldViewport extends Widget {
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
// Text is a sibling because Graphics may not have children; keep it right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
|
||||
+4
-13
@@ -14,11 +14,7 @@ export interface DrawItem {
|
||||
id: string;
|
||||
type: AssetType;
|
||||
name: string;
|
||||
/**
|
||||
* World position (parents already applied) – in the scene view including the editor
|
||||
* offsets along the chain, which is what makes an offset object be drawn, selected and
|
||||
* dragged at the shifted place. `z` is the draw order.
|
||||
*/
|
||||
/** World position, parents already applied, in the scene view including editor offsets. */
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
@@ -27,12 +23,7 @@ export interface DrawItem {
|
||||
parentId: string;
|
||||
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Picture to draw a sprite with, already resolved from the `texture` property – null for
|
||||
* every other type and for a sprite without a usable one. It is resolved here because
|
||||
* drawing has no access to the project: a `DrawItem` is meant to carry everything the
|
||||
* renderer needs.
|
||||
*/
|
||||
/** Resolved here because drawing has no access to the project. */
|
||||
texture: TextureRef | null;
|
||||
}
|
||||
|
||||
@@ -41,7 +32,7 @@ interface DrawList {
|
||||
background: number;
|
||||
}
|
||||
|
||||
/** The scene of the model, in tree order (the tie-break for equal `z`), drawn at the editor positions. */
|
||||
/** The model's scene, in tree order – the tie-break for equal `z` – at the editor positions. */
|
||||
export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList {
|
||||
const scene = state.activeScene;
|
||||
if (!scene) return { items: [], background: fallbackBackground };
|
||||
@@ -71,7 +62,7 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
|
||||
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
|
||||
}
|
||||
|
||||
/** The running world: scene content only – symbols are editor-only, switched-off objects do not exist for the game. */
|
||||
/** The running world: scene content only, since symbols are editor-only. */
|
||||
export function collectWorldItems(world: World): DrawList {
|
||||
const items = world.entities
|
||||
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
|
||||
|
||||
+10
-28
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* How the scene view draws its items: sprites as boxes, text as a hit box (the glyphs are a
|
||||
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
|
||||
* colliders. Everything here draws in world units at the item's origin.
|
||||
*/
|
||||
/** Everything here draws in world units at the item's origin. */
|
||||
import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js';
|
||||
import type { TextureRef } from '../core/model/texture';
|
||||
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
|
||||
@@ -12,13 +8,10 @@ import type { DrawItem } from './items';
|
||||
import { frameFillMatrix } from './textureFill';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
|
||||
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
|
||||
/** Screen-pixel half-width of the grab band along a collider's outline. */
|
||||
const COLLIDER_GRAB_BAND = 6;
|
||||
|
||||
/**
|
||||
* Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so
|
||||
* strokes and hit bands can stay constant on screen.
|
||||
*/
|
||||
/** `scale` is the view scale in pixels per world unit, so strokes stay constant on screen. */
|
||||
export function drawItem(
|
||||
node: Graphics,
|
||||
item: DrawItem,
|
||||
@@ -31,10 +24,8 @@ export function drawItem(
|
||||
switch (item.type) {
|
||||
case 'sprite': {
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
// Null while the picture is still loading, so the box shows its plain colour until then –
|
||||
// which is exactly what a sprite looked like before textures existed. No flicker, no
|
||||
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
|
||||
// as rgb(98,163,245)).
|
||||
// Null while the picture is still loading: the box shows its plain colour until then, and
|
||||
// once the picture is there the colour tints it.
|
||||
const texture = item.texture ? textures.get(item.texture) : null;
|
||||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture));
|
||||
return;
|
||||
@@ -47,7 +38,6 @@ export function drawItem(
|
||||
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
|
||||
return;
|
||||
case 'behavior': {
|
||||
// A diamond with a small "circuit" mark: the logic symbol.
|
||||
const r = BEHAVIOR_SYMBOL / 2;
|
||||
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
|
||||
node
|
||||
@@ -61,8 +51,6 @@ export function drawItem(
|
||||
return;
|
||||
}
|
||||
case 'container': {
|
||||
// A square with four small boxes inside: the group symbol. Its content is drawn as
|
||||
// ordinary objects (they are ordinary objects, parented below the container).
|
||||
const r = CONTAINER_SYMBOL / 2;
|
||||
node
|
||||
.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
|
||||
@@ -86,9 +74,8 @@ export function drawItem(
|
||||
);
|
||||
return;
|
||||
case 'collider': {
|
||||
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
|
||||
// top of their body, so only the outline band is hit-testable: clicks inside still reach
|
||||
// the sprite underneath, the edge grabs the collider.
|
||||
// Only the outline band is hit-testable, so clicks inside still reach the sprite that the
|
||||
// collider sits on.
|
||||
const shape = colliderShape(item.props, { x: 0, y: 0 });
|
||||
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
|
||||
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
|
||||
@@ -103,13 +90,8 @@ export function drawItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* The fill a sprite's box takes: the plain colour while there is no picture, the picture
|
||||
* tinted by that colour once there is one.
|
||||
*
|
||||
* Only a frame gets a matrix. An unmatrixed fill spreads the whole image over the shape, which
|
||||
* is precisely what a whole-image sprite wants, and skipping the matrix there saves Pixi the
|
||||
* inversion on every redraw of every such sprite. See `textureFill.ts` for why a frame cannot
|
||||
* be drawn as a Pixi sub-texture instead.
|
||||
* Only a frame gets a matrix: an unmatrixed fill already spreads the whole image over the shape,
|
||||
* and skipping it spares Pixi an inversion per redraw. See `textureFill.ts`.
|
||||
*/
|
||||
function spriteFill(
|
||||
texture: Texture | null,
|
||||
@@ -122,7 +104,7 @@ function spriteFill(
|
||||
return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) };
|
||||
}
|
||||
|
||||
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
|
||||
/** Hit area covering only the outline band, in local coordinates and world units. */
|
||||
function colliderOutlineHitArea(
|
||||
shape: ColliderShape,
|
||||
band: number,
|
||||
|
||||
@@ -11,31 +11,17 @@ export interface MatrixValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight`
|
||||
* image covers the filled shape – how a sprite wears one frame of a sheet.
|
||||
*
|
||||
* Measured, not assumed. Two things about Pixi v8 make this the answer:
|
||||
*
|
||||
* - A `Texture` built with a `frame` reports the frame's size, but a Graphics **fill ignores
|
||||
* it** and draws the whole source. Verified by reading back pixels: a fill with a 32×32
|
||||
* frame of a four-quadrant sheet showed all four quadrants. Sub-textures are therefore not
|
||||
* a way to draw a frame.
|
||||
* - `generateTextureMatrix` computes `uv = inverse(style.matrix) ∘ normalise(shapeBounds)`,
|
||||
* where the normaliser maps the shape's bounding box onto the unit square. So an absent
|
||||
* matrix spreads the whole image over the shape (which is why a whole-image sprite needs
|
||||
* none), and to spread a sub-rectangle instead, `inverse(style.matrix)` has to be the map
|
||||
* from the unit square onto that rectangle in UV space – making the matrix its inverse.
|
||||
*
|
||||
* Because the normaliser works off the shape's own bounds, this is independent of where the
|
||||
* shape sits and how big it is; verified with an off-origin, non-square shape.
|
||||
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight` image
|
||||
* covers the filled shape. A Pixi v8 Graphics fill ignores a `Texture`'s `frame` and draws the
|
||||
* whole source, so a frame has to be a fill matrix; Pixi inverts it against the shape's own
|
||||
* bounds, hence the inverse of "unit square onto the frame" here.
|
||||
*/
|
||||
export function frameFillMatrix(
|
||||
rect: FrameRect,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
): MatrixValues | null {
|
||||
// Without a known image size the rectangle has no reference frame; drawing the whole
|
||||
// picture is the honest answer, and it is what the sprite showed a moment ago anyway.
|
||||
// Without a known image size the rectangle has no reference frame: draw the whole picture.
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null;
|
||||
return {
|
||||
a: sourceWidth / rect.w,
|
||||
|
||||
+3
-12
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* How world coordinates map onto a viewport. Pure geometry – no Pixi, no editor state – so
|
||||
* both viewports share one definition of "where does this world point land on screen".
|
||||
*/
|
||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||
import { cameraFrame, rectAround, type WorldRect } from '../core/properties/camera';
|
||||
|
||||
/**
|
||||
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
|
||||
* pixels) at `scale` pixels per world unit. In the scene view the rectangle is the whole
|
||||
* viewport; in the game view it is the camera's frame fitted into it (letterboxed).
|
||||
* pixels) at `scale` pixels per world unit.
|
||||
*/
|
||||
export interface View {
|
||||
x: number;
|
||||
@@ -17,15 +12,12 @@ export interface View {
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Free-panning view: the whole viewport shows the world around (`x`, `y`) at `zoom`. */
|
||||
/** Free-panning view: the whole viewport is the rectangle. */
|
||||
export function freeView(x: number, y: number, zoom: number, w: number, h: number): View {
|
||||
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's frame scaled to fit inside `w`×`h` and centred – the remaining margin is the
|
||||
* letterbox, which is not part of the game area (see `isInside`).
|
||||
*/
|
||||
/** The camera's frame fitted into `w`×`h` and centred; the remaining margin is letterbox. */
|
||||
export function cameraView(camera: { x: number; y: number; props: PropertyBag }, w: number, h: number): View {
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(w / frame.w, h / frame.h);
|
||||
@@ -46,7 +38,6 @@ export function worldToScreen(view: View, wx: number, wy: number): Vec2 {
|
||||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||||
}
|
||||
|
||||
/** World region the view covers – what a redraw has to consider at all. */
|
||||
export function worldBounds(view: View): WorldRect {
|
||||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user