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');