478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
|
||
import { treeOrder, worldPosition } from '../core/model/hierarchy';
|
||
import { findAsset } from '../core/model/Project';
|
||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
|
||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||
import type { World } from '../runtime/World';
|
||
import { Theme, textStyle, Widget, type Size } from '../ui';
|
||
import type { EditorState } from '../editor/state';
|
||
|
||
/** What the viewport draws: the same shape whether it comes from the model or the world. */
|
||
interface DrawItem {
|
||
id: string;
|
||
type: AssetType;
|
||
name: string;
|
||
x: number;
|
||
y: number;
|
||
/** Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied). */
|
||
z: number;
|
||
props: PropertyBag;
|
||
/** Id of the parent item, empty when top-level. */
|
||
parentId: string;
|
||
}
|
||
|
||
/** World-unit size of the behavior symbol (a diamond). */
|
||
const BEHAVIOR_SYMBOL = 28;
|
||
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
|
||
const COLLIDER_GRAB_BAND = 6;
|
||
|
||
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
|
||
function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } {
|
||
if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band };
|
||
const hw = shape.w / 2;
|
||
const hh = shape.h / 2;
|
||
return {
|
||
contains: (x, y) => {
|
||
const ax = Math.abs(x);
|
||
const ay = Math.abs(y);
|
||
return ax <= hw + band && ay <= hh + band && (ax >= hw - band || ay >= hh - band);
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
|
||
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
|
||
* world unit. In edit mode the rectangle is the whole viewport; in play mode it is the
|
||
* camera's frame fitted into the viewport (letterboxed).
|
||
*/
|
||
interface View {
|
||
x: number;
|
||
y: number;
|
||
scale: number;
|
||
rect: { x: number; y: number; w: number; h: number };
|
||
}
|
||
|
||
const GRID_STEP = 100;
|
||
const LETTERBOX_COLOR = 0x000000;
|
||
|
||
export interface ViewportOptions {
|
||
/**
|
||
* Game view: draws the running world through the scene's camera, no editing. The default
|
||
* (scene view) draws the model – in play mode that is the runtime copy, so it shows the
|
||
* live positions and stays fully editable.
|
||
*/
|
||
game?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
|
||
* outline, drag-to-move, middle-drag panning and wheel zoom – in edit and play mode alike.
|
||
* The **game view** shows the running world: the scene's camera defines what is visible,
|
||
* only its frame is drawn (letterboxed), everything outside it is masked and not rendered
|
||
* at all. Without a camera the game view renders nothing.
|
||
*/
|
||
export class Viewport extends Widget {
|
||
private readonly game: boolean;
|
||
private readonly bg = new Graphics();
|
||
private readonly grid = new Graphics();
|
||
/** Game view only: shown while no world is running. */
|
||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
|
||
private readonly content = new Container();
|
||
private readonly world = new Container();
|
||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||
private 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.
|
||
*/
|
||
private readonly overlay = new Container();
|
||
/** Dashed owner links (behavior → owner), drawn in world units beneath the symbols. */
|
||
private readonly links = new Graphics();
|
||
private readonly contentMask = new Graphics();
|
||
private readonly gizmos = new Graphics();
|
||
private readonly nodes = new Map<string, Graphics>();
|
||
private readonly labels = new Map<string, Text>();
|
||
private playWorld: World | null = null;
|
||
/** Edit-mode view: world point at the viewport centre and zoom. */
|
||
private view = { x: 0, y: 0, zoom: 1 };
|
||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||
private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
|
||
private visibleCount = 0;
|
||
|
||
constructor(private readonly state: EditorState, options: ViewportOptions = {}) {
|
||
super();
|
||
this.game = options.game ?? false;
|
||
this.world.addChild(this.objects, this.links, this.overlay);
|
||
// Decorations must not swallow hits meant for the objects beneath them.
|
||
this.links.eventMode = 'none';
|
||
this.hint.eventMode = 'none';
|
||
this.hint.anchor.set(0.5);
|
||
this.content.addChild(this.world, this.gizmos);
|
||
this.addChild(this.bg, this.grid, this.content, this.contentMask, this.hint);
|
||
this.content.mask = this.contentMask;
|
||
this.cursor = 'default';
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
if (this.game) return;
|
||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.button === 0 && e.target === this) this.state.select(null);
|
||
});
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||
if (this.panning) {
|
||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
|
||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
|
||
this.redraw();
|
||
} else if (this.dragging) {
|
||
const local = this.toLocal(e.global);
|
||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||
if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return;
|
||
this.dragging.moved = true;
|
||
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
|
||
if (!object) return;
|
||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z };
|
||
this.state.preview();
|
||
}
|
||
});
|
||
const finish = () => {
|
||
this.panning = null;
|
||
const drag = this.dragging;
|
||
this.dragging = null;
|
||
if (!drag || !drag.moved) return;
|
||
const object = this.state.activeScene?.objects.find((o) => o.id === drag.objectId);
|
||
if (!object) return;
|
||
const next = { ...object.position };
|
||
object.position = { ...drag.start };
|
||
this.state.execute(new MoveSceneObjectCommand(drag.sceneId, drag.objectId, next, drag.start));
|
||
};
|
||
this.on('pointerup', finish);
|
||
this.on('pointerupoutside', finish);
|
||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||
if (this.game) return;
|
||
const local = this.toLocal(e.global);
|
||
const before = this.screenToWorld(local.x, local.y);
|
||
const factor = Math.exp(-e.deltaY * 0.0015);
|
||
this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor));
|
||
const after = this.screenToWorld(local.x, local.y);
|
||
this.view.x += before.x - after.x;
|
||
this.view.y += before.y - after.y;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
});
|
||
|
||
state.on('project', () => this.redraw());
|
||
state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; 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(); });
|
||
}
|
||
|
||
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
|
||
setWorld(world: World | null): void {
|
||
this.playWorld = world;
|
||
this.redraw();
|
||
}
|
||
|
||
/** World coordinates at the centre of the current edit view (used to place assets). */
|
||
worldCenter(): Vec2 {
|
||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||
}
|
||
|
||
/** Read-only facts for tests: 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 (test hook): 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 (test hook). */
|
||
drawOrder(): string[] {
|
||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
// --- transforms ------------------------------------------------------------------------
|
||
|
||
private editView(): View {
|
||
return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } };
|
||
}
|
||
|
||
/** The active mapping, or null when the game view has no world or no camera (nothing to show). */
|
||
private currentView(): View | null {
|
||
if (!this.game) return this.editView();
|
||
const camera = this.playWorld?.firstOfType('camera');
|
||
if (!camera) return null;
|
||
const frame = cameraFrame(camera.props);
|
||
const scale = Math.min(this.w / frame.w, this.h / frame.h);
|
||
const w = frame.w * scale;
|
||
const h = frame.h * scale;
|
||
return { x: camera.x, y: camera.y, scale, rect: { x: (this.w - w) / 2, y: (this.h - h) / 2, w, h } };
|
||
}
|
||
|
||
/** World region covered by a view. */
|
||
private worldBounds(view: View): WorldRect {
|
||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||
}
|
||
|
||
private screenToWorld(sx: number, sy: number, view: View = this.editView()): Vec2 {
|
||
const cx = view.rect.x + view.rect.w / 2;
|
||
const cy = view.rect.y + view.rect.h / 2;
|
||
return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y };
|
||
}
|
||
|
||
private worldToScreen(wx: number, wy: number, view: View = this.editView()): Vec2 {
|
||
const cx = view.rect.x + view.rect.w / 2;
|
||
const cy = view.rect.y + view.rect.h / 2;
|
||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||
}
|
||
|
||
// --- drawing ---------------------------------------------------------------------------
|
||
|
||
private collectItems(): { items: DrawItem[]; background: number } {
|
||
if (this.game) {
|
||
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
|
||
const world = this.playWorld;
|
||
if (!world) return { items: [], background: LETTERBOX_COLOR };
|
||
return {
|
||
background: world.backgroundColor,
|
||
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
|
||
};
|
||
}
|
||
const scene = this.state.activeScene;
|
||
if (!scene) return { items: [], background: Theme.colors.viewport };
|
||
const items: DrawItem[] = [];
|
||
// Tree order (parents before children) is the tie-break for equal z.
|
||
for (const { object } of treeOrder(scene)) {
|
||
const asset = findAsset(this.state.project, object.assetId);
|
||
if (!asset) continue;
|
||
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId });
|
||
}
|
||
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
|
||
}
|
||
|
||
/** Size of an item's footprint in world units (sprite box or camera frame). */
|
||
private itemSize(item: DrawItem): { w: number; h: number } {
|
||
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
|
||
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||
if (item.type === 'collider') return colliderSize(item.props);
|
||
return cameraFrame(item.props);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const editing = !this.game;
|
||
const view = this.currentView();
|
||
|
||
// Base fill: letterbox black in the game view, neutral in the scene view without a scene.
|
||
this.bg.clear().rect(0, 0, this.w, this.h).fill(editing ? Theme.colors.viewport : LETTERBOX_COLOR);
|
||
this.drawGrid(editing);
|
||
this.gizmos.clear();
|
||
this.contentMask.clear();
|
||
this.content.visible = view !== null;
|
||
this.hint.visible = this.game && !this.playWorld;
|
||
this.hint.position.set(this.w / 2, this.h / 2);
|
||
this.visibleCount = 0;
|
||
if (!view) return;
|
||
|
||
const { items, background } = this.collectItems();
|
||
const { rect } = view;
|
||
if (this.game || this.state.activeScene) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
|
||
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
|
||
|
||
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
|
||
this.world.scale.set(view.scale);
|
||
const bounds = this.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 = this.itemSize(item);
|
||
const onScreen = rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
|
||
let node = this.nodes.get(item.id);
|
||
if (!node) {
|
||
node = new Graphics();
|
||
node.label = item.id;
|
||
node.cursor = 'move';
|
||
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||
this.nodes.set(item.id, node);
|
||
(this.isSymbol(item) ? this.overlay : this.objects).addChild(node);
|
||
}
|
||
node.visible = onScreen;
|
||
node.eventMode = editing && onScreen ? 'static' : 'none';
|
||
this.syncLabel(item, editing && onScreen, view.scale);
|
||
if (!onScreen) continue;
|
||
this.visibleCount++;
|
||
node.position.set(item.x, item.y);
|
||
node.clear();
|
||
if (item.type === 'sprite') {
|
||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
|
||
} else if (item.type === 'camera') {
|
||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted });
|
||
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
|
||
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
|
||
} else if (item.type === '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.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
|
||
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
|
||
node.circle(6, 3, 2).fill(Theme.colors.accentText);
|
||
} else if (item.type === '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.
|
||
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);
|
||
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
|
||
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color });
|
||
node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / view.scale);
|
||
}
|
||
}
|
||
this.drawParentLinks(items, editing, view.scale);
|
||
for (const [id, node] of this.nodes) {
|
||
if (!seen.has(id)) {
|
||
node.destroy();
|
||
this.nodes.delete(id);
|
||
this.labels.get(id)?.destroy();
|
||
this.labels.delete(id);
|
||
}
|
||
}
|
||
this.syncDrawOrder(items);
|
||
|
||
if (editing) this.drawGizmos(items, view);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
private syncDrawOrder(items: DrawItem[]): void {
|
||
const ordered = sortByZ(items.filter((item) => !this.isSymbol(item)));
|
||
this.objects.removeChildren();
|
||
for (const item of ordered) {
|
||
const node = this.nodes.get(item.id);
|
||
if (node) this.objects.addChild(node);
|
||
}
|
||
}
|
||
|
||
/** Items drawn as editor symbols rather than as scene content. */
|
||
private isSymbol(item: DrawItem): boolean {
|
||
return isSymbolType(item.type);
|
||
}
|
||
|
||
/**
|
||
* Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only).
|
||
* 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.
|
||
*/
|
||
private drawParentLinks(items: DrawItem[], editing: boolean, scale: number): void {
|
||
this.links.clear();
|
||
if (!editing) return;
|
||
const byId = new Map(items.map((i) => [i.id, i]));
|
||
for (const item of items) {
|
||
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue;
|
||
const parent = byId.get(item.parentId);
|
||
if (!parent) continue;
|
||
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
|
||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
||
}
|
||
}
|
||
|
||
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
|
||
let label = this.labels.get(item.id);
|
||
if (!show || !this.isSymbol(item)) {
|
||
if (label) label.visible = false;
|
||
return;
|
||
}
|
||
if (!label) {
|
||
label = new Text({ text: '', style: textStyle('small') });
|
||
label.eventMode = 'none';
|
||
this.labels.set(item.id, label);
|
||
this.overlay.addChild(label);
|
||
}
|
||
const size = this.itemSize(item);
|
||
label.visible = true;
|
||
label.text = item.name;
|
||
label.scale.set(1 / scale);
|
||
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
|
||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||
}
|
||
|
||
private drawGrid(editing: boolean): void {
|
||
this.grid.clear();
|
||
if (!editing) return;
|
||
const step = GRID_STEP * this.view.zoom;
|
||
if (step < 12) return;
|
||
const origin = this.worldToScreen(0, 0);
|
||
const startX = ((origin.x % step) + step) % step;
|
||
const startY = ((origin.y % step) + step) % step;
|
||
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
|
||
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.05 });
|
||
if (origin.x >= 0 && origin.x <= this.w) this.grid.moveTo(origin.x, 0).lineTo(origin.x, this.h).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
|
||
if (origin.y >= 0 && origin.y <= this.h) this.grid.moveTo(0, origin.y).lineTo(this.w, origin.y).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
|
||
}
|
||
|
||
private drawGizmos(items: DrawItem[], view: View): void {
|
||
const s = this.state.selection;
|
||
if (!s || s.kind !== 'object') return;
|
||
const item = items.find((i) => i.id === s.objectId);
|
||
if (!item) return;
|
||
const { w, h } = this.itemSize(item);
|
||
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
|
||
const sw = w * view.scale;
|
||
const sh = h * view.scale;
|
||
this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
|
||
for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
|
||
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
|
||
}
|
||
}
|
||
|
||
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||
if (this.game || e.button !== 0) return;
|
||
const scene = this.state.activeScene;
|
||
const object = scene?.objects.find((o) => o.id === objectId);
|
||
if (!scene || !object) return;
|
||
e.stopPropagation();
|
||
this.state.select({ kind: 'object', sceneId: scene.id, objectId });
|
||
const local = this.toLocal(e.global);
|
||
this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
|
||
}
|
||
}
|
||
|
||
/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
|
||
export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
|
||
const length = Math.hypot(x2 - x1, y2 - y1);
|
||
if (length === 0) return;
|
||
const ux = (x2 - x1) / length;
|
||
const uy = (y2 - y1) / length;
|
||
for (let d = 0; d < length; d += dash + gap) {
|
||
const end = Math.min(length, d + dash);
|
||
g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end);
|
||
}
|
||
}
|