From 056a62252424586643c0e2068afcdd7879f2cc96 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 23 Sep 2026 06:57:43 +0200 Subject: [PATCH] Refactoring round 6: shared sheet canvas, fewer copies (#85) Recurring cleanup round. Behaviour is unchanged. Duplicated code in src went from 27 clones with 299 lines to 6 clones with 56 lines (jscpd, the two identical OFL licence texts not counted). - SheetCanvas is the new base of the texture, pixel art and vector art canvases. It holds background, mask, hint, panning, wheel zoom, fit, view save and restore and the screen mapping. Pixel art keeps its whole-pixel panning and fixed zoom steps. - mixColor in src/core/util/color.ts replaces three copies in animation, gradient and particles. It clamps t like the particle one. - fingerprint now lives once in generated.ts, next to the revision contract from #185. - MoveCommand next to RenameCommand is the base of the three reorder commands for tile pictures, tilemap layers and vector shapes. - The two raycast nodes share inputs, settings and filter via rayOf. - canvasRect() lives once in AssetTabEditor. - Removed unused code: findPixelArt, isDrawableShape, GeneratedPictures.has and SnapToggle.snapping. Tests now check the same things through equivalent paths. - 38 values used only in their own file lost their export. - Three comments that told history now describe the current state. The bundle stayed md5-identical for those edits. - README lists vector art, pixel art and palette, and links the two wiki pages. Findings: - PaintTilesCommand.changes is meant to prevent empty undo steps but has not been called since #128. The rectangle tool therefore records a no-op undo step when it paints the same tile over itself. Left as is, because fixing it changes behaviour. - No test covered middle-click panning in any sheet tab before. 13f and 48a now check it. - The default values of the ray inputs were untested. Length 1000 could become 10 without a red test. tests/core/raycast.test.ts now covers it. - CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for #164. The sum is now 1310 (1308 + 2), computed. Tests: - ./check.sh: 144 files, 2143 tests, exit 0 (before 2142). - e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks. - e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks. - Counter-checks, each restored via cp and cmp: halved panning fails both new pan checks; unclamped mix fails 1 test, no blue channel 3; coarser hash 3; broken reorder undo 2; triggers setting forced on 2; bent ray length 1. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- src/core/commands/Command.ts | 19 ++++ src/core/commands/tileCommands.ts | 22 ++-- src/core/commands/tilemapCommands.ts | 22 ++-- src/core/commands/vectorArtCommands.ts | 22 ++-- src/core/graph/nested.ts | 2 +- src/core/graph/nodes/action/Raycast.ts | 68 +++++++----- src/core/graph/nodes/action/RaycastAll.ts | 40 ++----- src/core/graph/sharedState.ts | 2 +- src/core/graph/values.ts | 8 +- src/core/model/animation.ts | 12 +- src/core/model/generated.ts | 10 ++ src/core/model/pixelCel.ts | 2 +- src/core/model/pixelEdit.ts | 6 +- src/core/model/pixelart.ts | 18 +-- src/core/model/texture.ts | 10 +- src/core/model/traceCollision.ts | 2 +- src/core/model/vectorCurves.ts | 6 +- src/core/model/vectorPaint.ts | 10 +- src/core/model/vectorPath.ts | 2 +- src/core/model/vectorart.ts | 16 +-- src/core/particles/nodes.ts | 11 +- src/core/properties/bounds.ts | 8 +- src/core/properties/collider.ts | 4 +- src/core/util/color.ts | 10 ++ src/editor/AssetTabEditor.ts | 6 + src/editor/container/ContainerEditor.ts | 6 - src/editor/graph/NodeView.ts | 8 +- src/editor/graph/SnapToggle.ts | 4 - src/editor/pixelart/PixelArtCanvas.ts | 97 ++++------------- src/editor/pixelart/PixelPreview.ts | 2 +- src/editor/pixelart/pixelView.ts | 2 +- src/editor/texture/SheetCanvas.ts | 127 ++++++++++++++++++++++ src/editor/texture/TextureCanvas.ts | 119 +++----------------- src/editor/texture/TextureEditor.ts | 6 - src/editor/vectorart/VectorArtCanvas.ts | 118 +++----------------- src/editor/vectorart/VectorArtEditor.ts | 10 +- src/editor/viewport/WorldViewport.ts | 4 +- src/runtime/systems/BehaviorSystem.ts | 2 +- src/runtime/systems/solids.ts | 2 +- src/viewport/generatedPictures.ts | 6 +- src/viewport/pixelArtPicture.ts | 2 +- src/viewport/vectorArtPicture.ts | 4 +- tests/core/particles.test.ts | 2 +- tests/core/raycast.test.ts | 21 +++- tests/core/vectorart.test.ts | 2 - tests/editor/snap-toggle.test.ts | 3 - tests/viewport/generated-pictures.test.ts | 2 +- tools/e2e/13-frames.mjs | 7 ++ tools/e2e/47-vectorart.mjs | 2 +- tools/e2e/48-pixelart.mjs | 14 +++ 51 files changed, 393 insertions(+), 525 deletions(-) create mode 100644 src/core/util/color.ts create mode 100644 src/editor/texture/SheetCanvas.ts diff --git a/README.md b/README.md index 8b157d01..c79c0cf7 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ way in. ## In one screen - An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, rigidbody, - container, texture, font, sound, animation, state machine, shader, particles, variables, tile, - tilemap) with properties and no position. + container, texture, vector art, pixel art, palette, font, sound, animation, state machine, shader, + particles, variables, tile, tilemap) with properties and no position. - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. - A **container** is a reusable group edited once and placed many times; its content is resolved @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1307 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1310 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs @@ -67,6 +67,8 @@ CI. Details, the GPU requirement and the detached runner are in | [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts | | [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step | | [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits | +| [Vector art](https://git.nockal.com/meleki/game-maker/wiki/Vector-Art) | Pictures drawn from shapes: the pen, curves, gradients and texture fills, combining shapes | +| [Pixel art](https://git.nockal.com/meleki/game-maker/wiki/Pixel-Art) | Pictures painted pixel by pixel: the palette, the tools, layers and frames | | [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to | | [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` | | [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab | diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts index 420c6233..35dac126 100644 --- a/src/core/commands/Command.ts +++ b/src/core/commands/Command.ts @@ -30,6 +30,25 @@ export abstract class RenameCommand implements Command { protected abstract rename(project: Project, from: string, to: string): void; } +/** A move within a list that is undone by moving back. */ +export abstract class MoveCommand implements Command { + constructor( + readonly label: string, + private readonly from: number, + private readonly to: number, + ) {} + + execute(project: Project): void { + this.move(project, this.from, this.to); + } + + undo(project: Project): void { + this.move(project, this.to, this.from); + } + + protected abstract move(project: Project, from: number, to: number): void; +} + /** A rejected edit with a message fit for the user; the editor shows it as a notice. */ export class CommandError extends Error { constructor(message: string) { diff --git a/src/core/commands/tileCommands.ts b/src/core/commands/tileCommands.ts index bb27142a..bf8e7e5c 100644 --- a/src/core/commands/tileCommands.ts +++ b/src/core/commands/tileCommands.ts @@ -6,7 +6,7 @@ import type { Project } from '../model/Project'; import { resolvePicture } from '../model/texture'; import { moveItem } from '../util/list'; -import { type Command, CommandError } from './Command'; +import { type Command, CommandError, MoveCommand } from './Command'; import { requireTile } from './require'; /** Appends a picture; `at` puts it before the picture with that index instead. */ @@ -58,24 +58,16 @@ export class RemoveTilePictureCommand implements Command { } /** Moves one picture to another place in the list; the step in between is what changes the animation. */ -export class MoveTilePictureCommand implements Command { - readonly label = 'Reorder tile pictures'; - +export class MoveTilePictureCommand extends MoveCommand { constructor( private readonly assetId: string, - private readonly from: number, - private readonly to: number, - ) {} - - execute(project: Project): void { - this.move(project, this.from, this.to); + from: number, + to: number, + ) { + super('Reorder tile pictures', from, to); } - undo(project: Project): void { - this.move(project, this.to, this.from); - } - - private move(project: Project, from: number, to: number): void { + protected move(project: Project, from: number, to: number): void { const asset = requireTile(project, this.assetId); if (asset.pictures[from] === undefined) throw new CommandError(`${asset.name} has no picture ${from + 1}`); diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 711e4e04..7b118b78 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -31,7 +31,7 @@ import { localMatrixOf, transformOfObject } from '../properties/transform'; import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { copyName } from '../util/names'; import { nextNumberedName } from './naming'; -import { type Command, CommandError } from './Command'; +import { type Command, CommandError, MoveCommand } from './Command'; import { requireTilemap, requireTilemapLayer } from './require'; import { ObjectEditCommand } from './sceneObjectCommands'; @@ -228,24 +228,16 @@ export class RemoveTilemapLayerCommand implements Command { } /** Moves a layer in the drawing order; the last layer in the list is drawn on top. */ -export class MoveTilemapLayerCommand implements Command { - readonly label = 'Reorder layers'; - +export class MoveTilemapLayerCommand extends MoveCommand { constructor( private readonly assetId: string, - private readonly from: number, - private readonly to: number, - ) {} - - execute(project: Project): void { - this.move(project, this.from, this.to); + from: number, + to: number, + ) { + super('Reorder layers', from, to); } - undo(project: Project): void { - this.move(project, this.to, this.from); - } - - private move(project: Project, from: number, to: number): void { + protected move(project: Project, from: number, to: number): void { if (!moveItem(requireTilemap(project, this.assetId).layers, from, to)) throw new CommandError('No such layer'); } diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index d034e494..c50d388b 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -16,7 +16,7 @@ import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vecto import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint'; import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import { copyName } from '../util/names'; -import { type Command, CommandError } from './Command'; +import { type Command, CommandError, MoveCommand } from './Command'; import { nextNumberedName } from './naming'; import { requireVectorArt, requireVectorShape } from './require'; @@ -214,24 +214,16 @@ export class RenameVectorShapeCommand implements Command { } /** Moves a shape in the drawing order; the last shape in the list is drawn on top. */ -export class MoveVectorShapeCommand implements Command { - readonly label = 'Reorder shapes'; - +export class MoveVectorShapeCommand extends MoveCommand { constructor( private readonly assetId: string, - private readonly from: number, - private readonly to: number, - ) {} - - execute(project: Project): void { - this.move(project, this.from, this.to); + from: number, + to: number, + ) { + super('Reorder shapes', from, to); } - undo(project: Project): void { - this.move(project, this.to, this.from); - } - - private move(project: Project, from: number, to: number): void { + protected move(project: Project, from: number, to: number): void { if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to)) throw new CommandError('No such shape'); } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index eab794c2..6c32d38a 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -67,7 +67,7 @@ export function fixedPortKeys(def: NodeDef): string[] { return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)]; } -export function declaredOn( +function declaredOn( def: NodeDef, ports: readonly DeclaredPort[], side: 'inputs' | 'outputs', diff --git a/src/core/graph/nodes/action/Raycast.ts b/src/core/graph/nodes/action/Raycast.ts index faec4e44..5580808a 100644 --- a/src/core/graph/nodes/action/Raycast.ts +++ b/src/core/graph/nodes/action/Raycast.ts @@ -1,11 +1,45 @@ import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers'; -import type { NodeDef } from '../../NodeRegistry'; -import { asBoolean, asNumber } from '../../values'; +import type { PropertyBag } from '../../../model/Properties'; +import type { PropertyDef } from '../../../properties/PropertySchema'; +import type { NodeContext } from '../../NodeContext'; +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asBoolean, asNumber, type Value } from '../../values'; import { bool, exec, num, obj, withDefault } from '../shared'; -/** The mask of layers a ray looks at – shared with `Raycast all` (#157) once that exists. */ +/** The mask of layers a ray looks at (#158). */ export const RAY_LAYERS_PARAM = 'layers'; +/** Where a ray starts, which way it points and how far it reaches – `Raycast all` takes the same. */ +export const RAY_INPUTS: readonly PortDef[] = [ + num('fromX', 'from x'), + num('fromY', 'from y'), + withDefault(num('dirX', 'dir x'), 1), + num('dirY', 'dir y'), + withDefault(num('length'), 1000), +]; + +/** What a ray passes over and which layers it looks at, so both ray nodes are filtered alike. */ +export const RAY_PARAMS: readonly PropertyDef[] = [ + { key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true }, + { key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false }, + // Every layer, so a graph written before there was a choice answers as it did (#158). + { key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS }, +]; + +/** The ray a node's inputs and settings describe, in the form `NodeContext.raycast` takes it. */ +export function rayOf(ctx: NodeContext, inputs: Record, params: PropertyBag) { + return { + from: { x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) }, + direction: { x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) }, + length: asNumber(inputs.length), + options: { + ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null, + triggers: asBoolean(params.triggers ?? false), + layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS), + }, + }; +} + const MISSED = { hit: false, object: null, @@ -28,14 +62,7 @@ export const Raycast: NodeDef = { '"Layers", triggers only with "Hit triggers", a one-way one only from the side it blocks from. What ' + 'the ray starts inside is not met, and with "Ignore owner" neither is the owner or anything parented ' + 'below it.', - inputs: [ - exec(), - num('fromX', 'from x'), - num('fromY', 'from y'), - withDefault(num('dirX', 'dir x'), 1), - num('dirY', 'dir y'), - withDefault(num('length'), 1000), - ], + inputs: [exec(), ...RAY_INPUTS], outputs: [ exec(), bool('hit'), @@ -47,23 +74,10 @@ export const Raycast: NodeDef = { num('normalY', 'normal y'), num('distance'), ], - params: [ - { key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true }, - { key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false }, - // Every layer, so a graph written before there was a choice answers as it did (#158). - { key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS }, - ], + params: [...RAY_PARAMS], run: (ctx, inputs, params) => { - const hit = ctx.raycast( - { x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) }, - { x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) }, - asNumber(inputs.length), - { - ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null, - triggers: asBoolean(params.triggers ?? false), - layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS), - }, - ); + const ray = rayOf(ctx, inputs, params); + const hit = ctx.raycast(ray.from, ray.direction, ray.length, ray.options); if (!hit) return { next: 'exec', outputs: MISSED }; return { next: 'exec', diff --git a/src/core/graph/nodes/action/RaycastAll.ts b/src/core/graph/nodes/action/RaycastAll.ts index 30274793..0dbe63a2 100644 --- a/src/core/graph/nodes/action/RaycastAll.ts +++ b/src/core/graph/nodes/action/RaycastAll.ts @@ -1,9 +1,8 @@ -import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers'; import type { RayOutcome } from '../../NodeContext'; import type { NodeDef } from '../../NodeRegistry'; -import { asBoolean, asNumber, type ValueList } from '../../values'; -import { exec, int, list, num, withDefault } from '../shared'; -import { RAY_LAYERS_PARAM } from './Raycast'; +import { asNumber, type ValueList } from '../../values'; +import { exec, int, list, num } from '../shared'; +import { RAY_INPUTS, RAY_PARAMS, rayOf } from './Raycast'; /** * The hits as parallel lists: item 0 of every list is the nearest hit, item 1 the next, and so on @@ -30,15 +29,7 @@ export const RaycastAll: NodeDef = { 'come out sorted by distance, one item per hit, and "item at" the same index in each of them ' + 'belongs to the same hit. A collider counts once, however many pieces it is made of; a tilemap ' + 'once per cell the ray goes through. "max hits" above 0 keeps that many of the nearest.', - inputs: [ - exec(), - num('fromX', 'from x'), - num('fromY', 'from y'), - withDefault(num('dirX', 'dir x'), 1), - num('dirY', 'dir y'), - withDefault(num('length'), 1000), - int('maxHits', 0, 'max hits'), - ], + inputs: [exec(), ...RAY_INPUTS, int('maxHits', 0, 'max hits')], outputs: [ exec(), num('count'), @@ -50,24 +41,13 @@ export const RaycastAll: NodeDef = { list('normalsY', 'normals y'), list('distances'), ], - params: [ - { key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true }, - { key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false }, - // The same param as `Raycast` carries, so both nodes are filtered the same way (#158). - { key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS }, - ], + params: [...RAY_PARAMS], run: (ctx, inputs, params) => { - const hits = ctx.raycastAll( - { x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) }, - { x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) }, - asNumber(inputs.length), - { - ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null, - triggers: asBoolean(params.triggers ?? false), - layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS), - maxHits: Math.max(0, Math.floor(asNumber(inputs.maxHits))), - }, - ); + const ray = rayOf(ctx, inputs, params); + const hits = ctx.raycastAll(ray.from, ray.direction, ray.length, { + ...ray.options, + maxHits: Math.max(0, Math.floor(asNumber(inputs.maxHits))), + }); return { next: 'exec', outputs: listsOf(hits) }; }, }; diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts index 8d56b674..93a606c2 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -110,7 +110,7 @@ export function variableBag(ctx: NodeContext, source: string): PropertyBag { } /** Where a machine's current state is kept: shared unless the machine is `local`, else per instance. */ -export function stateBag(ctx: NodeContext, machineId: string): Record { +function stateBag(ctx: NodeContext, machineId: string): Record { return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; } diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index a927ba8d..ae95516b 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -26,7 +26,7 @@ export function isEntity(value: Value | undefined): value is EntityHandle { } /** A list of anything a data edge carries – a handle among the items is what `isList` cannot say. */ -export function isValueList(value: Value | undefined): value is ValueList { +function isValueList(value: Value | undefined): value is ValueList { return Array.isArray(value); } @@ -63,7 +63,7 @@ export function asValueItem(value: Value | undefined): ValueList[number] { } /** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */ -export function asListItem(value: Value | undefined): number | string | boolean | StructValue { +function asListItem(value: Value | undefined): number | string | boolean | StructValue { if (isStructValue(value)) return value; return asPrimitive(value); } @@ -128,7 +128,7 @@ function isStructValue(value: Value | undefined): value is StructValue { * A struct slot keeps the fields it was declared with: what arrives fills the ones it names and * the rest stay put. That is what lets one node set some values of a bundle without erasing it. */ -export function mergeStruct(current: StructValue, value: Value | undefined): StructValue { +function mergeStruct(current: StructValue, value: Value | undefined): StructValue { const incoming = asStruct(value); const merged: Record = {}; for (const [field, own] of Object.entries(current)) @@ -137,7 +137,7 @@ export function mergeStruct(current: StructValue, value: Value | undefined): Str } /** `value` converted to the type the slot already holds, so a number stays a number. */ -export function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue { +function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue { if (isList(current)) return storedList(asList(value)); if (isStruct(current)) return mergeStruct(current, value); if (typeof current === 'number') return asNumber(value); diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index acf3d65e..3bc2f9a0 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -8,6 +8,7 @@ import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; import type { SceneObject } from './SceneObject'; +import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ export const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const; @@ -103,15 +104,6 @@ export function blends(kind: string): boolean { return kind === 'number' || kind === 'color'; } -function lerpColor(a: number, b: number, t: number): number { - const channel = (shift: number) => { - const from = (a >> shift) & 0xff; - const to = (b >> shift) & 0xff; - return Math.round(from + (to - from) * t) << shift; - }; - return channel(16) | channel(8) | channel(0); -} - /** In the order the inspector offers them; `linear` first, since it is what an absent field means. */ export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [ { value: 'linear', label: 'Linear' }, @@ -155,7 +147,7 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: const to = Number(b); return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a; } - if (kind === 'color') return lerpColor(colorOf(a, 0), colorOf(b, 0), t); + if (kind === 'color') return mixColor(colorOf(a, 0), colorOf(b, 0), t); return a; } diff --git a/src/core/model/generated.ts b/src/core/model/generated.ts index 2dfe1dc9..f841d7f3 100644 --- a/src/core/model/generated.ts +++ b/src/core/model/generated.ts @@ -59,6 +59,16 @@ export function generatedPictureOf(asset: Asset, project?: Project): GeneratedPi return picture.width > 0 && picture.height > 0 ? picture : null; } +/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@`. */ +export function fingerprint(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(36); +} + export function generatedKey(assetId: string, revision: string | number): string { return `${PREFIX}${assetId}@${revision}`; } diff --git a/src/core/model/pixelCel.ts b/src/core/model/pixelCel.ts index 22c0e32d..95e6e0da 100644 --- a/src/core/model/pixelCel.ts +++ b/src/core/model/pixelCel.ts @@ -13,7 +13,7 @@ const MAGIC = [0x47, 0x4d, 0x50, 0x58]; const VERSION = 1; -export const CEL_HEADER_BYTES = 10; +const CEL_HEADER_BYTES = 10; /** The largest side a canvas may have (answer 3). */ export const PIXEL_CANVAS_MAX = 512; diff --git a/src/core/model/pixelEdit.ts b/src/core/model/pixelEdit.ts index 91024999..4596b648 100644 --- a/src/core/model/pixelEdit.ts +++ b/src/core/model/pixelEdit.ts @@ -22,7 +22,7 @@ export function pixelArtState(asset: PixelArtAsset): PixelArtState { return cloneState(asset); } -export function cloneState(state: PixelArtState): PixelArtState { +function cloneState(state: PixelArtState): PixelArtState { return { width: state.width, height: state.height, @@ -52,7 +52,7 @@ function frameIndex(state: PixelArtState, frameId: string): number { } /** "Layer n" with the first number no layer uses yet. */ -export function nextLayerName(state: PixelArtState): string { +function nextLayerName(state: PixelArtState): string { const taken = new Set(state.layers.map((layer) => layer.name)); for (let n = state.layers.length + 1; ; n++) if (!taken.has(`Layer ${n}`)) return `Layer ${n}`; } @@ -229,6 +229,6 @@ export function withCelsReplaced( return next; } -export function isCanvasSide(side: number): boolean { +function isCanvasSide(side: number): boolean { return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX; } diff --git a/src/core/model/pixelart.ts b/src/core/model/pixelart.ts index 442ff70f..8a11337b 100644 --- a/src/core/model/pixelart.ts +++ b/src/core/model/pixelart.ts @@ -5,9 +5,9 @@ * files (see `pixelCel.ts`); whoever composes a frame is handed a way to read them. */ import type { Asset, PaletteAsset, PixelArtAsset, PixelFrame, PixelLayer } from './Asset'; -import type { GeneratedPicture } from './generated'; +import { fingerprint, type GeneratedPicture } from './generated'; import { type CelPixels, PIXEL_CANVAS_MAX } from './pixelCel'; -import { findAsset, findAssetOf, type Project } from './Project'; +import { findAssetOf, type Project } from './Project'; import { textOf } from './Properties'; export const PALETTE_KEY = 'palette'; @@ -153,15 +153,6 @@ export function pixelArtRevision(asset: PixelArtAsset, colors: readonly number[] ); } -function fingerprint(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash.toString(36); -} - /** What the picture path is told (#185): the sheet of all frames side by side. */ export function pixelArtPicture(asset: Asset, project?: Project): GeneratedPicture | null { if (asset.type !== 'pixelart' || asset.frames.length === 0) return null; @@ -213,8 +204,3 @@ export function pixelArtWornId(asset: PixelArtAsset): string { export function isPixelArt(asset: Asset | undefined | null): asset is PixelArtAsset { return asset?.type === 'pixelart'; } - -export function findPixelArt(project: Project, assetId: string): PixelArtAsset | null { - const asset = findAsset(project, assetId); - return isPixelArt(asset) ? asset : null; -} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 5953c020..915b974d 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -53,7 +53,7 @@ export interface NineSlice { export const SLICE_SCALE_KEY = 'sliceScale'; /** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */ -export function sliceScaleOf(asset: TextureAsset): number { +function sliceScaleOf(asset: TextureAsset): number { const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1); return Number.isFinite(value) && value > 0 ? value : 1; } @@ -129,11 +129,11 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n }; } -export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 }; +const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 }; export const DEFAULT_FILL: FrameFill = 'repeat'; /** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */ -export const MIN_SLICE_MIDDLE = 1; +const MIN_SLICE_MIDDLE = 1; /** The frame's collision outlines (#141), a copy; empty when it has none. */ export function frameCollision(frame: TextureFrame): Vec2[][] { @@ -158,12 +158,12 @@ export function frameFill(frame: TextureFrame): FrameFill { return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL; } -export function hasBorders(border: FrameBorders): boolean { +function hasBorders(border: FrameBorders): boolean { return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0; } /** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */ -export function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null { +function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null { const border = normalizeBorders(frameBorders(frame), frame.w, frame.h); if (!hasBorders(border)) return null; return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale }; diff --git a/src/core/model/traceCollision.ts b/src/core/model/traceCollision.ts index bb9c830c..eebb82f3 100644 --- a/src/core/model/traceCollision.ts +++ b/src/core/model/traceCollision.ts @@ -72,7 +72,7 @@ export function alphaOfRect( return alpha; } -export function clampPoints(value: number): number { +function clampPoints(value: number): number { const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints); return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n)); } diff --git a/src/core/model/vectorCurves.ts b/src/core/model/vectorCurves.ts index 883bc8d8..23825810 100644 --- a/src/core/model/vectorCurves.ts +++ b/src/core/model/vectorCurves.ts @@ -1,7 +1,7 @@ /** * Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight - * pieces, so a curve has to be cut into them – and until now the result was corners only, even where - * nothing had crossed the curve at all. + * pieces, so a curve has to be cut into them – and on its own the result would be corners only, even + * where nothing crossed the curve at all. * * The way back is to remember where every sampled point came from: which cubic, and at which `t` on * it. A run of result points that walks one cubic in one direction is then that cubic again, cut at @@ -222,7 +222,7 @@ function lerp(a: Vec2, b: Vec2, t: number): Vec2 { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; } -export function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 { +function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 { const u = 1 - t; const a = u * u * u; const b = 3 * u * u * t; diff --git a/src/core/model/vectorPaint.ts b/src/core/model/vectorPaint.ts index 14ae07a0..bce4063e 100644 --- a/src/core/model/vectorPaint.ts +++ b/src/core/model/vectorPaint.ts @@ -11,6 +11,7 @@ import { isGeneratedFile } from './generated'; import type { Project } from './Project'; import type { Vec2 } from './Properties'; import { resolvePicture, type TextureRef } from './texture'; +import { mixColor } from '../util/color'; /** What the fill of a shape is made of; `solid` is the shape's own `fill` colour. */ export type FillKind = 'solid' | 'linear' | 'radial' | 'texture'; @@ -120,15 +121,6 @@ export function colorAt(stops: readonly GradientStop[], offset: number): { color return { color: last.color, alpha: last.alpha }; } -function mixColor(a: number, b: number, t: number): number { - const channel = (shift: number): number => { - const from = (a >> shift) & 0xff; - const to = (b >> shift) & 0xff; - return Math.round(from + (to - from) * t) << shift; - }; - return channel(16) | channel(8) | channel(0); -} - /** A stop added at `offset`, in the colour the gradient already has there; it goes last in the list. */ export function withStopAdded(gradient: VectorGradient, offset: number): VectorGradient { const at = clamp01(offset); diff --git a/src/core/model/vectorPath.ts b/src/core/model/vectorPath.ts index bd117e65..9993658c 100644 --- a/src/core/model/vectorPath.ts +++ b/src/core/model/vectorPath.ts @@ -97,7 +97,7 @@ export function shapeDrawing(shape: VectorShape, project?: Project): ShapeDrawin * than two stops or no length, a picture that is not there or a tile of no size. A shape that went * blank because its texture was deleted would be lost on the canvas; its colour keeps it findable. */ -export function drawPaint(paint: VectorPaint, project?: Project): DrawPaint | null { +function drawPaint(paint: VectorPaint, project?: Project): DrawPaint | null { const from = { x: paint.from.x, y: paint.from.y }; const to = { x: paint.to.x, y: paint.to.y }; if (paint.type === 'texture') { diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts index 3843895e..38ba4968 100644 --- a/src/core/model/vectorart.ts +++ b/src/core/model/vectorart.ts @@ -9,7 +9,7 @@ * lies outside the canvas is cut off (answer 3) – otherwise a sprite's size would be a lie. */ import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset'; -import type { GeneratedPicture } from './generated'; +import { fingerprint, type GeneratedPicture } from './generated'; import type { Project } from './Project'; import type { PropertyBag, Vec2 } from './Properties'; import { paintText, textureFillText } from './vectorPaint'; @@ -45,10 +45,6 @@ export function isDrawableRing(ring: VectorRing): boolean { return ring.points.length >= (ring.closed ? 3 : 2); } -export function isDrawableShape(shape: VectorShape): boolean { - return shape.rings.some(isDrawableRing); -} - /** How far a shape reaches, in canvas pixels, handles included; null when it has no point at all. */ export function shapeExtent(shape: VectorShape): { x0: number; y0: number; x1: number; y1: number } | null { let x0 = Infinity; @@ -114,16 +110,6 @@ function handleText(handle: Vec2 | undefined): string { return handle ? `${handle.x},${handle.y}` : '-'; } -/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@` (see #185). */ -function fingerprint(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash.toString(36); -} - /** * What the picture path is told about a vector art asset (#185). An asset without a drawable shape * still has a canvas: an empty picture is what an empty drawing looks like, and a sprite wearing it diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts index dcd2f9ce..b4a3752d 100644 --- a/src/core/particles/nodes.ts +++ b/src/core/particles/nodes.ts @@ -3,6 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties'; import { propertyNodeParams } from '../properties/declaredRows'; import { flatDef } from '../properties/PropertySchema'; import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles'; +import { mixColor } from '../util/color'; /** What one particle is, at one moment: everything a graph may read about it. */ export interface ParticleEnv { @@ -431,16 +432,6 @@ export const ParticleMixColor: ParticleNodeDef = { }), }; -export function mixColor(a: number, b: number, t: number): number { - const k = t < 0 ? 0 : t > 1 ? 1 : t; - const channel = (shift: number) => { - const from = (a >> shift) & 0xff; - const value = Math.round(from + (((b >> shift) & 0xff) - from) * k); - return (value < 0 ? 0 : value > 255 ? 255 : value) << shift; - }; - return channel(16) | channel(8) | channel(0); -} - export const ParticleStep: ParticleNodeDef = { type: 'ParticleStep', label: 'Step', diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index cbf236ec..ff5c6874 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -24,10 +24,10 @@ import { defaultsFor } from './PropertySchema'; const TEXT_DEFAULTS = defaultsFor('text'); /** Symbol sizes in world units. */ -export const BEHAVIOR_SYMBOL = 28; -export const CONTAINER_SYMBOL = 24; -export const PARTICLES_SYMBOL = 24; -export const RIGIDBODY_SYMBOL = 24; +const BEHAVIOR_SYMBOL = 28; +const CONTAINER_SYMBOL = 24; +const PARTICLES_SYMBOL = 24; +const RIGIDBODY_SYMBOL = 24; /** Rough width of one character relative to the font size, for a plain sans. */ const CHAR_WIDTH = 0.55; diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 7b9de2fb..9c9431d0 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -58,10 +58,10 @@ export function outlinesOf(shape: Polygon): Vec2[][] { /** Where a polygon collider keeps its corners (#151). */ export const COLLIDER_POINTS_KEY = 'points'; /** Which frame a collider of the shape "Frame collision" wears (#193): a frame id, as a sprite's texture holds it. */ -export const COLLIDER_FRAME_KEY = 'frame'; +const COLLIDER_FRAME_KEY = 'frame'; /** Whether the collider wears a frame's outline (#193), which only the project can resolve. */ -export function wearsFrame(props: PropertyBag): boolean { +function wearsFrame(props: PropertyBag): boolean { return props.shape === 'frame'; } diff --git a/src/core/util/color.ts b/src/core/util/color.ts new file mode 100644 index 00000000..8877c30e --- /dev/null +++ b/src/core/util/color.ts @@ -0,0 +1,10 @@ +/** Between two `0xrrggbb` colours, channel by channel; `t` is held to 0…1. */ +export function mixColor(a: number, b: number, t: number): number { + const k = t < 0 ? 0 : t > 1 ? 1 : t; + const channel = (shift: number): number => { + const from = (a >> shift) & 0xff; + const to = (b >> shift) & 0xff; + return Math.round(from + (to - from) * k) << shift; + }; + return channel(16) | channel(8) | channel(0); +} diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index b701c674..cc871e3c 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -124,6 +124,12 @@ export abstract class AssetTabEditor< this.canvas.restoreView?.(view); } + /** The canvas's area on screen, without the header – so a check can ask what lies outside it. */ + canvasRect(): { x: number; y: number; w: number; h: number } { + const p = this.canvas.getGlobalPosition(); + return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h }; + } + /** Enable/disable whatever `extras` the subclass passed to `mount`. */ protected syncExtras(_asset: A | null): void {} diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index 897ef0df..c926c056 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -22,12 +22,6 @@ export class ContainerEditor extends AssetTabEditor { private readonly onion = new Container(); private readonly tiles = new Container(); private readonly art = new Sprite(); private readonly grid = new Graphics(); private readonly overlay = new Graphics(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); - private readonly clip = new Graphics(); private readonly pictures = new Map(); - private view: SheetView = { panX: 0, panY: 0, zoom: 1 }; - private assetId = ''; - private needsFit = true; - private panning: { x: number; y: number; panX: number; panY: number } | null = null; private stroke: Stroke | null = null; private hover: PixelPoint | null = null; /** Which frame and layer is being painted, per asset, so a tab switch comes back to them. */ @@ -130,22 +121,11 @@ export class PixelArtCanvas extends Widget { constructor(private readonly state: EditorState) { super(); - this.content.addChild(this.checker, this.onion, this.tiles, this.art, this.grid, this.overlay); - this.addChild(this.bg, this.content, this.hint, this.clip); - this.content.mask = this.clip; - this.content.eventMode = 'none'; - this.hint.eventMode = 'none'; - this.focusable = true; - - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); - this.on('pointerup', () => this.onPointerUp()); - this.on('pointerupoutside', () => this.onPointerUp()); + this.content.addChild(this.onion, this.tiles, this.art, this.grid, this.overlay); this.on('pointerleave', () => { this.hover = null; this.redraw(); }); - this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); state.on('project', () => this.redraw()); state.on('load', () => { this.places.clear(); @@ -161,10 +141,6 @@ export class PixelArtCanvas extends Widget { super.destroy(options); } - override measure(): Size { - return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; - } - show(assetId: string): void { if (this.assetId === assetId) return; this.cancelStroke(); @@ -174,27 +150,6 @@ export class PixelArtCanvas extends Widget { this.onChange(); } - get currentAssetId(): string { - return this.assetId; - } - - saveView(): { view: SheetView; needsFit: boolean } { - return { view: { ...this.view }, needsFit: this.needsFit }; - } - - restoreView(saved: unknown): void { - const { view, needsFit } = saved as { view: SheetView; needsFit: boolean }; - if (needsFit) return; - this.view = { ...view }; - this.needsFit = false; - this.redraw(); - } - - fit(): void { - this.needsFit = true; - this.redraw(); - } - get zoom(): number { return this.view.zoom; } @@ -268,17 +223,11 @@ export class PixelArtCanvas extends Widget { return cel === null ? emptyCel(asset.width, asset.height) : cel; } - private onPointerDown(e: FederatedPointerEvent): void { - const asset = this.asset(); - if (!asset) return; - const local = this.toLocal(e.global); - if (e.button === 1 || (e.button === 0 && e.shiftKey)) { - this.panning = { x: local.x, y: local.y, panX: this.view.panX, panY: this.view.panY }; - e.stopPropagation(); - return; - } - if (e.button !== 0) return; - ui().focus.focus(this); + protected pressTarget(): PixelArtAsset | null { + return this.asset(); + } + + protected press(asset: PixelArtAsset, e: FederatedPointerEvent, local: Vec2): void { e.stopPropagation(); const at = pixelUnder(this.view, local.x, local.y); // Alt picks with any tool (answer 6), so the colour is at hand without leaving the pencil. @@ -352,18 +301,12 @@ export class PixelArtCanvas extends Widget { this.setColor(0); } - private onPointerMove(e: FederatedPointerEvent): void { - const local = this.toLocal(e.global); - if (this.panning) { - this.view = { - ...this.view, - panX: Math.round(this.panning.panX + local.x - this.panning.x), - panY: Math.round(this.panning.panY + local.y - this.panning.y), - }; - this.needsFit = false; - this.redraw(); - return; - } + protected override panTo(panX: number, panY: number): void { + this.view = { ...this.view, panX: Math.round(panX), panY: Math.round(panY) }; + this.needsFit = false; + } + + protected move(local: Vec2): void { const at = pixelUnder(this.view, local.x, local.y); const stroke = this.stroke; if (stroke) { @@ -380,8 +323,7 @@ export class PixelArtCanvas extends Widget { } } - private onPointerUp(): void { - this.panning = null; + protected release(): void { const stroke = this.stroke; this.stroke = null; cancelFrameWork(this.frameKey); @@ -409,7 +351,7 @@ export class PixelArtCanvas extends Widget { ); } - private onWheel(e: FederatedWheelEvent): void { + protected override onWheel(e: FederatedWheelEvent): void { const local = this.toLocal(e.global); const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1); if (zoom !== this.view.zoom) { @@ -449,8 +391,7 @@ export class PixelArtCanvas extends Widget { } override redraw(): void { - this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); - this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.drawGround(); this.checker.clear(); this.grid.clear(); this.overlay.clear(); diff --git a/src/editor/pixelart/PixelPreview.ts b/src/editor/pixelart/PixelPreview.ts index 11b48381..7fccf56f 100644 --- a/src/editor/pixelart/PixelPreview.ts +++ b/src/editor/pixelart/PixelPreview.ts @@ -7,7 +7,7 @@ import type { EditorState } from '../state'; import { drawChecker } from '../texture/checker'; const HEIGHT = 96; -export const PREVIEW_FPS_DEFAULT = 8; +const PREVIEW_FPS_DEFAULT = 8; /** * The frames of a pixel art asset playing in a loop (answer 5), small, as the picture path draws them diff --git a/src/editor/pixelart/pixelView.ts b/src/editor/pixelart/pixelView.ts index 06d94d8d..ced07055 100644 --- a/src/editor/pixelart/pixelView.ts +++ b/src/editor/pixelart/pixelView.ts @@ -5,7 +5,7 @@ import type { SheetView } from '../texture/sheetView'; /** The zoom steps (answer 9: whole steps from 1x to 32x). Doubling alone jumps too far in the middle. */ -export const ZOOM_STEPS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32] as const; +const ZOOM_STEPS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32] as const; /** From this zoom on the pixel grid is drawn; below it the lines would be denser than the pixels. */ export const GRID_FROM_ZOOM = 8; diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts new file mode 100644 index 00000000..b27e89df --- /dev/null +++ b/src/editor/texture/SheetCanvas.ts @@ -0,0 +1,127 @@ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import type { Vec2 } from '../../core/model/Properties'; +import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { imageToLocal, type SheetView, zoomAbout } from './sheetView'; + +/** + * The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art + * and Vector art tabs. It owns the ground, the clip, the hint and the view: a middle drag or a + * Shift drag pans, the wheel zooms, Fit fits, and each document keeps its view. What a left press + * does is the tab's; it only gets one while `pressTarget` has something to press on. + */ +export abstract class SheetCanvas extends Widget { + protected readonly bg = new Graphics(); + /** Everything the view moves; the checker board is its bottom layer. */ + protected readonly content = new Container(); + protected readonly checker = new Graphics(); + protected readonly hint = new Text({ text: '', style: textStyle('muted') }); + protected readonly clip = new Graphics(); + protected view: SheetView = { panX: 0, panY: 0, zoom: 1 }; + protected assetId = ''; + /** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */ + protected needsFit = true; + private panning: { pointer: Vec2; panX: number; panY: number } | null = null; + + constructor() { + super(); + this.content.addChild(this.checker); + this.addChild(this.bg, this.content, this.hint, this.clip); + this.content.mask = this.clip; + this.content.eventMode = 'none'; + this.hint.eventMode = 'none'; + this.focusable = true; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + } + + /** What a press works on; while null every press is ignored, a pan included. */ + protected abstract pressTarget(): A | null; + protected abstract press(target: A, e: FederatedPointerEvent, local: Vec2): void; + /** The pointer moved while no pan runs. */ + protected abstract move(local: Vec2): void; + protected abstract release(): void; + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + get currentAssetId(): string { + return this.assetId; + } + + saveView(): { view: SheetView; needsFit: boolean } { + return { view: { ...this.view }, needsFit: this.needsFit }; + } + + restoreView(saved: unknown): void { + const { view, needsFit } = saved as { view: SheetView; needsFit: boolean }; + if (needsFit) return; + this.view = { ...view }; + this.needsFit = false; + this.redraw(); + } + + fit(): void { + this.needsFit = true; + this.redraw(); + } + + /** Where a picture pixel sits on screen; the check scripts aim their drags with it. */ + imageToScreen(x: number, y: number): Vec2 { + const local = imageToLocal(this.view, x, y); + const origin = this.getGlobalPosition(); + return { x: origin.x + local.x, y: origin.y + local.y }; + } + + /** The pan a drag has reached; the pixel art tab keeps it on whole screen pixels. */ + protected panTo(panX: number, panY: number): void { + this.view = { ...this.view, panX, panY }; + } + + protected onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); + this.needsFit = false; + this.redraw(); + e.preventDefault(); + } + + protected drawGround(): void { + this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); + this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + } + + private onPointerDown(e: FederatedPointerEvent): void { + const target = this.pressTarget(); + if (!target) return; + const local = this.toLocal(e.global); + if (e.button === 1 || (e.button === 0 && e.shiftKey)) { + this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; + e.stopPropagation(); + return; + } + if (e.button !== 0) return; + ui().focus.focus(this); + this.press(target, e, { x: local.x, y: local.y }); + } + + private onPointerMove(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + const panning = this.panning; + if (!panning) { + this.move({ x: local.x, y: local.y }); + return; + } + this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)); + this.redraw(); + } + + private onPointerUp(): void { + this.panning = null; + this.release(); + } +} diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index fc125b98..03c630ec 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -1,11 +1,4 @@ -import { - Container, - type FederatedPointerEvent, - type FederatedWheelEvent, - Graphics, - Sprite, - Text, -} from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; import { AddFrameCommand, SetFrameCollisionCommand, @@ -28,7 +21,7 @@ import { setFrameCollision, setFrameSlice, } from '../../core/model/texture'; -import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { pressCount, Theme, textStyle, type Widget } from '../../ui'; import { gripAt, insideOutline, @@ -42,6 +35,7 @@ import { dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import type { EditorState } from '../state'; import { drawChecker } from './checker'; +import { SheetCanvas } from './SheetCanvas'; import { borderAt, borderCursor, @@ -59,8 +53,6 @@ import { imageToLocal, localToImage, rectBetween, - type SheetView, - zoomAbout, } from './sheetView'; /** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */ @@ -113,22 +105,11 @@ interface NewFrame { * 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(); - /** Everything the view can move: panned or zoomed, all of it reaches past the widget. */ - private readonly content = new Container(); - private readonly checker = new Graphics(); +export class TextureCanvas extends SheetCanvas { private readonly image = new Sprite(); private readonly overlay = new Graphics(); private readonly labels = new Map(); private readonly labelLayer = new Container(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); - private readonly clip = new Graphics(); - private view: SheetView = { panX: 0, panY: 0, zoom: 1 }; - private assetId = ''; - /** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */ - private needsFit = true; - private panning: { pointer: Vec2; panX: number; panY: number } | null = null; private dragging: FrameDrag | null = null; private spanning: NewFrame | null = null; private corner: CornerDrag | null = null; @@ -140,18 +121,7 @@ export class TextureCanvas extends Widget { constructor(private readonly state: EditorState) { super(); - this.content.addChild(this.checker, this.image, this.overlay, this.labelLayer); - this.addChild(this.bg, this.content, this.hint, this.clip); - this.content.mask = this.clip; - this.content.eventMode = 'none'; - this.hint.eventMode = 'none'; - this.focusable = true; - - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); - this.on('pointerup', () => this.onPointerUp()); - this.on('pointerupoutside', () => this.onPointerUp()); - this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + this.content.addChild(this.image, this.overlay, this.labelLayer); state.on('project', () => this.redraw()); state.on('selection', () => this.redraw()); @@ -169,10 +139,6 @@ export class TextureCanvas extends Widget { super.destroy(options); } - override measure(): Size { - return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; - } - /** Switching resets the view, since the next picture is a new subject. */ show(assetId: string): void { if (this.assetId === assetId) return; @@ -181,22 +147,6 @@ export class TextureCanvas extends Widget { this.redraw(); } - get currentAssetId(): string { - return this.assetId; - } - - saveView(): { view: SheetView; needsFit: boolean } { - return { view: { ...this.view }, needsFit: this.needsFit }; - } - - restoreView(saved: unknown): void { - const { view, needsFit } = saved as { view: SheetView; needsFit: boolean }; - if (needsFit) return; - this.view = { ...view }; - this.needsFit = false; - this.redraw(); - } - setCollisionMode(on: boolean): void { if (this.collision === on) return; this.collision = on; @@ -207,18 +157,6 @@ export class TextureCanvas extends Widget { return this.collision; } - fit(): void { - this.needsFit = true; - this.redraw(); - } - - /** 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(); - return { x: origin.x + local.x, y: origin.y + local.y }; - } - private asset(): TextureAsset | null { const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; return asset && asset.type === 'texture' ? asset : null; @@ -259,19 +197,16 @@ export class TextureCanvas extends Widget { return true; } - private onPointerDown(e: FederatedPointerEvent): void { + /** + * Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame that + * lies off the sheet. + */ + protected pressTarget(): TextureAsset | null { const asset = this.asset(); - // 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)) { - this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; - e.stopPropagation(); - return; - } - if (e.button !== 0) return; - ui().focus.focus(this); + return asset && asset.width > 0 && asset.height > 0 ? asset : null; + } + + protected press(asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void { const point = localToImage(this.view, local.x, local.y); const tolerance = HANDLE_SIZE / this.view.zoom; if (this.collision) { @@ -430,17 +365,7 @@ export class TextureCanvas extends Widget { }; } - private onPointerMove(e: FederatedPointerEvent): void { - const local = this.toLocal(e.global); - if (this.panning) { - this.view = { - ...this.view, - panX: this.panning.panX + (local.x - this.panning.pointer.x), - panY: this.panning.panY + (local.y - this.panning.pointer.y), - }; - this.redraw(); - return; - } + protected move(local: Vec2): void { if (this.corner) { const corner = this.corner; const frame = this.frame(corner.frameId); @@ -502,8 +427,7 @@ export class TextureCanvas extends Widget { this.state.preview(); } - private onPointerUp(): void { - this.panning = null; + protected release(): void { const corner = this.corner; this.corner = null; const frame = corner && this.frame(corner.frameId); @@ -553,14 +477,6 @@ export class TextureCanvas extends Widget { this.redraw(); } - private onWheel(e: FederatedWheelEvent): void { - const local = this.toLocal(e.global); - this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); - this.needsFit = false; - this.redraw(); - e.preventDefault(); - } - private updateCursor(local: Vec2): void { const asset = this.asset(); if (!asset) { @@ -594,8 +510,7 @@ export class TextureCanvas extends Widget { } override redraw(): void { - this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); - this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.drawGround(); const asset = this.asset(); const texture = asset && asset.file ? this.pictureOf(asset) : null; diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index aed47cdd..8d324c44 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -41,12 +41,6 @@ export class TextureEditor extends AssetTabEditor { return this.canvas.imageToScreen(x, y); } - /** The drawing area without the header, so a check can ask what lies outside it. */ - canvasRect(): { x: number; y: number; w: number; h: number } { - const p = this.canvas.getGlobalPosition(); - return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h }; - } - override selectedId(): string { return this.state.selectedTextureId ?? ''; } diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index a2465469..5d98bbd0 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -10,7 +10,7 @@ * directly as a preview and puts it back on release, so one drag is one undo step – the same way * the Texture tab edits a collision outline. */ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { AddVectorShapeCommand, RemoveVectorShapeCommand, @@ -33,17 +33,7 @@ import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint'; import { vectorArtDrawings } from '../../core/model/vectorPath'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture'; -import { - cancelFrameWork, - onFrame, - pressCount, - type Size, - Theme, - textStyle, - ui, - Widget, - zoomFactor, -} from '../../ui'; +import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui'; import { insideRing, type PointKind, @@ -61,7 +51,8 @@ import { } from '../polygonEdit'; import { type EditorState, shapeSelection } from '../state'; import { drawChecker } from '../texture/checker'; -import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView'; +import { SheetCanvas } from '../texture/SheetCanvas'; +import { fitView, imageToLocal, localToImage } from '../texture/sheetView'; /** * Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint @@ -79,7 +70,7 @@ const HANDLE_RADIUS = 4; /** Pixels the pointer must travel before a press becomes a drag rather than a click. */ const DRAG_THRESHOLD = 3; /** The grid points snap to while snapping is on, in canvas pixels. */ -export const SNAP_STEP = 8; +const SNAP_STEP = 8; /** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */ interface ShapeDrag { @@ -115,21 +106,11 @@ interface PaintDrag { /** Counts canvases so each gets its own key in the frame queue. */ let instances = 0; -export class VectorArtCanvas extends Widget { - private readonly bg = new Graphics(); - /** Everything the view moves: the drawing, scaled by the zoom, and the overlay above it. */ - private readonly content = new Container(); - private readonly checker = new Graphics(); +export class VectorArtCanvas extends SheetCanvas { private readonly art = new Graphics(); private readonly overlay = new Graphics(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); - private readonly clip = new Graphics(); - private view: SheetView = { panX: 0, panY: 0, zoom: 1 }; - private assetId = ''; - private needsFit = true; private tool: VectorTool = 'select'; private snap = false; - private panning: { pointer: Vec2; panX: number; panY: number } | null = null; private drag: ShapeDrag | null = null; private paintDrag: PaintDrag | null = null; private target: PaintTarget = 'fill'; @@ -147,18 +128,7 @@ export class VectorArtCanvas extends Widget { constructor(private readonly state: EditorState) { super(); - this.content.addChild(this.checker, this.art, this.overlay); - this.addChild(this.bg, this.content, this.hint, this.clip); - this.content.mask = this.clip; - this.content.eventMode = 'none'; - this.hint.eventMode = 'none'; - this.focusable = true; - - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); - this.on('pointerup', () => this.onPointerUp()); - this.on('pointerupoutside', () => this.onPointerUp()); - this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + this.content.addChild(this.art, this.overlay); state.on('project', () => this.redraw()); state.on('selection', () => { @@ -181,10 +151,6 @@ export class VectorArtCanvas extends Widget { super.destroy(options); } - override measure(): Size { - return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; - } - show(assetId: string): void { if (this.assetId === assetId) return; this.assetId = assetId; @@ -194,27 +160,6 @@ export class VectorArtCanvas extends Widget { this.redraw(); } - get currentAssetId(): string { - return this.assetId; - } - - saveView(): { view: SheetView; needsFit: boolean } { - return { view: { ...this.view }, needsFit: this.needsFit }; - } - - restoreView(saved: unknown): void { - const { view, needsFit } = saved as { view: SheetView; needsFit: boolean }; - if (needsFit) return; - this.view = { ...view }; - this.needsFit = false; - this.redraw(); - } - - fit(): void { - this.needsFit = true; - this.redraw(); - } - setTool(tool: VectorTool): void { if (this.tool === tool) return; this.tool = tool; @@ -285,13 +230,6 @@ export class VectorArtCanvas extends Widget { this.onPointPicked(); } - /** Where a canvas pixel sits on screen; the check scripts aim their drags with it. */ - canvasToScreen(x: number, y: number): Vec2 { - const local = imageToLocal(this.view, x, y); - const origin = this.getGlobalPosition(); - return { x: origin.x + local.x, y: origin.y + local.y }; - } - private asset(): VectorArtAsset | null { const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; return asset && asset.type === 'vectorart' ? asset : null; @@ -332,17 +270,11 @@ export class VectorArtCanvas extends Widget { return true; } - private onPointerDown(e: FederatedPointerEvent): void { - const asset = this.asset(); - if (!asset) return; - const local = this.toLocal(e.global); - if (e.button === 1 || (e.button === 0 && e.shiftKey)) { - this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; - e.stopPropagation(); - return; - } - if (e.button !== 0) return; - ui().focus.focus(this); + protected pressTarget(): VectorArtAsset | null { + return this.asset(); + } + + protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void { const at = localToImage(this.view, local.x, local.y); const tolerance = POINT_SIZE / this.view.zoom; if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance); @@ -556,17 +488,7 @@ export class VectorArtCanvas extends Widget { else this.redraw(); } - private onPointerMove(e: FederatedPointerEvent): void { - const local = this.toLocal(e.global); - if (this.panning) { - this.view = { - ...this.view, - panX: this.panning.panX + (local.x - this.panning.pointer.x), - panY: this.panning.panY + (local.y - this.panning.pointer.y), - }; - this.redraw(); - return; - } + protected move(local: Vec2): void { if (this.paintDrag) { this.movePaint(this.paintDrag, local); return; @@ -650,8 +572,7 @@ export class VectorArtCanvas extends Widget { this.state.preview(); } - private onPointerUp(): void { - this.panning = null; + protected release(): void { if (this.paintDrag) { this.releasePaint(this.paintDrag); return; @@ -708,14 +629,6 @@ export class VectorArtCanvas extends Widget { this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle')); } - private onWheel(e: FederatedWheelEvent): void { - const local = this.toLocal(e.global); - this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); - this.needsFit = false; - this.redraw(); - e.preventDefault(); - } - private updateCursor(local: Vec2): void { const asset = this.asset(); if (!asset) { @@ -772,8 +685,7 @@ export class VectorArtCanvas extends Widget { } override redraw(): void { - this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); - this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.drawGround(); const asset = this.asset(); const size = asset ? vectorCanvasSize(asset.props) : null; diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index 11a4e9d3..c753d5aa 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -12,7 +12,7 @@ const INFO_WIDTH = 190; /** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ const POINT_WIDTH = 150; -export const POINT_KIND_LABELS: Record = { +const POINT_KIND_LABELS: Record = { straight: 'Straight', corner: 'Corner', smooth: 'Smooth', @@ -112,13 +112,7 @@ export class VectorArtEditor extends AssetTabEditor { return this.kept.get(key)?.picture ?? null; } - has(key: string): boolean { - return this.kept.has(key); - } - /** The keys held, in the order they were drawn – what a test reads to see a revision go. */ keys(): string[] { return [...this.kept.keys()]; @@ -87,7 +83,7 @@ export function registerPictureGenerator(type: string, draw: PictureGenerator): }; } -export function pictureGeneratorFor(type: string): PictureGenerator | null { +function pictureGeneratorFor(type: string): PictureGenerator | null { return generators.get(type) ?? null; } diff --git a/src/viewport/pixelArtPicture.ts b/src/viewport/pixelArtPicture.ts index 6d94d32b..6f4be012 100644 --- a/src/viewport/pixelArtPicture.ts +++ b/src/viewport/pixelArtPicture.ts @@ -32,7 +32,7 @@ export function installPixelArtPictures(): () => void { } /** The sheet as a texture; null while a cel is loading, and nothing is remembered for a null. */ -export function rasterisePixelArt(asset: Asset, project?: Project): Texture | null { +function rasterisePixelArt(asset: Asset, project?: Project): Texture | null { if (asset.type !== 'pixelart' || asset.frames.length === 0) return null; const rgba = composeSheet(asset, paletteColors(project, asset), (file) => pixelCels.read(file)); if (!rgba) return null; diff --git a/src/viewport/vectorArtPicture.ts b/src/viewport/vectorArtPicture.ts index bc6a9f52..958f3116 100644 --- a/src/viewport/vectorArtPicture.ts +++ b/src/viewport/vectorArtPicture.ts @@ -36,7 +36,7 @@ import { textureLibrary } from './TextureLibrary'; * 256 canvas therefore lies on the GPU as 512 pixels, and the editor's own view is at most twice * the canvas before it shows pixels instead of curves. */ -export const SUPERSAMPLE = 2; +const SUPERSAMPLE = 2; /** Registers vector art in both halves of the picture path; the returned function takes it out. */ export function installVectorArtPictures(renderer: Renderer): () => void { @@ -59,7 +59,7 @@ export function installVectorArtPictures(renderer: Renderer): () => void { * null, so the next ask – the redraw that follows the picture landing – rasterises it complete, * instead of a drawing with a hole in it staying on screen under a key that never changes. */ -export function rasteriseVectorArt(asset: Asset, renderer: Renderer, project?: Project): Texture | null { +function rasteriseVectorArt(asset: Asset, renderer: Renderer, project?: Project): Texture | null { const canvas = vectorArtCanvas(asset, asset.props, project); if (!canvas) return null; const graphics = new Graphics(); diff --git a/tests/core/particles.test.ts b/tests/core/particles.test.ts index 139b6904..da40adb8 100644 --- a/tests/core/particles.test.ts +++ b/tests/core/particles.test.ts @@ -17,9 +17,9 @@ import { liveRange, prewarmOffset, } from '../../src/core/particles/emission'; +import { mixColor } from '../../src/core/util/color'; import { hashId, - mixColor, PARTICLE_NODES, PARTICLE_OUTPUT, PARTICLE_OUTPUTS, diff --git a/tests/core/raycast.test.ts b/tests/core/raycast.test.ts index c5dc1dc3..1ed78ec0 100644 --- a/tests/core/raycast.test.ts +++ b/tests/core/raycast.test.ts @@ -16,7 +16,9 @@ import { findAsset } from '../../src/core/model/Project'; import { TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { colliderShape, type ColliderShape } from '../../src/core/properties/collider'; -import { RAY_LAYERS_PARAM } from '../../src/core/graph/nodes/action/Raycast'; +import type { NodeDef } from '../../src/core/graph/NodeRegistry'; +import { Raycast, RAY_LAYERS_PARAM } from '../../src/core/graph/nodes/action/Raycast'; +import { RaycastAll } from '../../src/core/graph/nodes/action/RaycastAll'; import { rayCast } from '../../src/core/properties/ray'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { RAY_SHOWN } from '../../src/runtime/World'; @@ -516,3 +518,20 @@ describe('the rays a run remembers for drawing (#153)', () => { expect(r.world.rays.map((ray) => ray.to.x)).toEqual([20, 30]); }); }); + +describe('the two ray nodes (#157)', () => { + it('start from the same inputs and settings, a ray to the right 1000 units long', () => { + const shared = (def: NodeDef) => ({ + inputs: def.inputs.filter((port) => port.key !== 'maxHits'), + params: def.params, + }); + expect(shared(RaycastAll)).toEqual(shared(Raycast)); + const defaults = Object.fromEntries(Raycast.inputs.map((port) => [port.key, port.default])); + expect(defaults).toMatchObject({ dirX: 1, length: 1000 }); + expect(Raycast.params.map((param) => [param.key, param.default])).toEqual([ + ['ignoreOwner', true], + ['triggers', false], + [RAY_LAYERS_PARAM, ALL_LAYERS], + ]); + }); +}); diff --git a/tests/core/vectorart.test.ts b/tests/core/vectorart.test.ts index 5d4c8ffa..0b446283 100644 --- a/tests/core/vectorart.test.ts +++ b/tests/core/vectorart.test.ts @@ -17,7 +17,6 @@ import { resolvePicture } from '../../src/core/model/texture'; import { handleAt, isDrawableRing, - isDrawableShape, SHAPE_FILL_DEFAULT, shapeExtent, shapeOrderTarget, @@ -86,7 +85,6 @@ describe('what is worth drawing', () => { expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false); expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true); expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true); - expect(isDrawableShape({ id: 's', name: 'S', rings: [], fill: 0, opacity: 1 })).toBe(false); }); it('measures a shape including the handles pulled off its points', () => { diff --git a/tests/editor/snap-toggle.test.ts b/tests/editor/snap-toggle.test.ts index 75512917..8b54b227 100644 --- a/tests/editor/snap-toggle.test.ts +++ b/tests/editor/snap-toggle.test.ts @@ -30,15 +30,12 @@ describe('SnapToggle', () => { it('starts off and switches the target on and off again, lit while on', () => { const canvas = target(); const snap = new SnapToggle(() => canvas); - expect(snap.snapping).toBe(false); expect(snap.button.toggled).toBe(false); snap.button.onClick?.(); - expect(snap.snapping).toBe(true); expect(snap.button.toggled).toBe(true); snap.button.onClick?.(); - expect(snap.snapping).toBe(false); expect(snap.button.toggled).toBe(false); expect(canvas.calls).toEqual([true, false]); }); diff --git a/tests/viewport/generated-pictures.test.ts b/tests/viewport/generated-pictures.test.ts index 7526eddb..46b41b04 100644 --- a/tests/viewport/generated-pictures.test.ts +++ b/tests/viewport/generated-pictures.test.ts @@ -59,7 +59,7 @@ describe('generated pictures kept in memory', () => { const f = library(); f.answerWith(() => null); expect(f.pictures.get('gen:a@1')).toBeNull(); - expect(f.pictures.has('gen:a@1')).toBe(false); + expect(f.pictures.peek('gen:a@1')).toBeNull(); f.answerWith(() => 'ready'); expect(f.pictures.get('gen:a@1')).toBe('ready'); expect(f.drawn).toEqual(['a', 'a']); diff --git a/tools/e2e/13-frames.mjs b/tools/e2e/13-frames.mjs index 538faa16..b87451b1 100644 --- a/tools/e2e/13-frames.mjs +++ b/tools/e2e/13-frames.mjs @@ -192,6 +192,13 @@ export async function frames(t) { (await json('__gm.textOverflows()')).length === 0, JSON.stringify(await json('__gm.textOverflows()')), ); + await drag(centre.x, centre.y, centre.x + 50, centre.y + 30, { steps: 6, button: 'middle' }); + const pannedTo = await json('__gm.texture.pointAt(0, 0)'); + check( + 'a middle drag pans the sheet by as much as the pointer moved', + Math.abs(pannedTo.x - topLeft.x - 50) < 0.01 && Math.abs(pannedTo.y - topLeft.y - 30) < 0.01, + JSON.stringify({ topLeft, pannedTo }), + ); await clickText('Fit'); // 13g. A sprite pointed at a frame slicing removed falls back to its colour. diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 0488d778..a18ea9b3 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -229,7 +229,7 @@ export async function vectorArt(t) { JSON.stringify(moved), ); - // The magnet is the graph's (#194): the tab has no Grid button of its own any more, and with it + // The magnet is the graph's (#194): the tab has no Grid button of its own, and with it // on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it. const magnet = await json('__gm.vectorArt.snapButton()'); check( diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 19a1aaed..2fecfdb3 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -127,6 +127,20 @@ export async function pixelArt(t) { }), ); + // A middle drag pans by what the pointer moved; the way back leaves the view as the steps below expect it. + const origin = await json('__gm.pixelArt.pixelAt(0, 0)'); + const middle = await json('__gm.pixelArt.pixelAt(16, 16)'); + await drag(middle.x, middle.y, middle.x + 30, middle.y + 20, { steps: 6, button: 'middle' }); + const panned = await json('__gm.pixelArt.pixelAt(0, 0)'); + await drag(middle.x + 30, middle.y + 20, middle.x, middle.y, { steps: 6, button: 'middle' }); + check( + 'a middle drag pans the canvas by as much as the pointer moved, and back', + panned.x - origin.x === 30 && + panned.y - origin.y === 20 && + JSON.stringify(await json('__gm.pixelArt.pixelAt(0, 0)')) === JSON.stringify(origin), + JSON.stringify({ origin, panned, back: await json('__gm.pixelArt.pixelAt(0, 0)') }), + ); + // 48b. The pencil: a swatch picks the colour, a drag paints a row, and one undo takes the stroke back. await swatch(RED); await press_('pencil');