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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7c0b7cfdb
commit
056a622524
51 files changed
+387
-519
No files matched your search
@@ -15,8 +15,8 @@ way in.
|
|||||||
## In one screen
|
## In one screen
|
||||||
|
|
||||||
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, rigidbody,
|
- 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,
|
container, texture, vector art, pixel art, palette, font, sound, animation, state machine, shader,
|
||||||
tilemap) with properties and no position.
|
particles, variables, tile, tilemap) with properties and no position.
|
||||||
- A **scene object** places an asset: position, sparse **overrides** of its properties, an optional
|
- 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 ↺ / ↑.
|
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
|
- 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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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` |
|
| [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 |
|
| [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab |
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ export abstract class RenameCommand implements Command {
|
|||||||
protected abstract rename(project: Project, from: string, to: string): void;
|
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. */
|
/** A rejected edit with a message fit for the user; the editor shows it as a notice. */
|
||||||
export class CommandError extends Error {
|
export class CommandError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { resolvePicture } from '../model/texture';
|
import { resolvePicture } from '../model/texture';
|
||||||
import { moveItem } from '../util/list';
|
import { moveItem } from '../util/list';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError, MoveCommand } from './Command';
|
||||||
import { requireTile } from './require';
|
import { requireTile } from './require';
|
||||||
|
|
||||||
/** Appends a picture; `at` puts it before the picture with that index instead. */
|
/** 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. */
|
/** Moves one picture to another place in the list; the step in between is what changes the animation. */
|
||||||
export class MoveTilePictureCommand implements Command {
|
export class MoveTilePictureCommand extends MoveCommand {
|
||||||
readonly label = 'Reorder tile pictures';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly from: number,
|
from: number,
|
||||||
private readonly to: number,
|
to: number,
|
||||||
) {}
|
) {
|
||||||
|
super('Reorder tile pictures', from, to);
|
||||||
execute(project: Project): void {
|
|
||||||
this.move(project, this.from, this.to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
protected move(project: Project, from: number, to: number): void {
|
||||||
this.move(project, this.to, this.from);
|
|
||||||
}
|
|
||||||
|
|
||||||
private move(project: Project, from: number, to: number): void {
|
|
||||||
const asset = requireTile(project, this.assetId);
|
const asset = requireTile(project, this.assetId);
|
||||||
if (asset.pictures[from] === undefined)
|
if (asset.pictures[from] === undefined)
|
||||||
throw new CommandError(`${asset.name} has no picture ${from + 1}`);
|
throw new CommandError(`${asset.name} has no picture ${from + 1}`);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { localMatrixOf, transformOfObject } from '../properties/transform';
|
|||||||
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||||
import { copyName } from '../util/names';
|
import { copyName } from '../util/names';
|
||||||
import { nextNumberedName } from './naming';
|
import { nextNumberedName } from './naming';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError, MoveCommand } from './Command';
|
||||||
import { requireTilemap, requireTilemapLayer } from './require';
|
import { requireTilemap, requireTilemapLayer } from './require';
|
||||||
import { ObjectEditCommand } from './sceneObjectCommands';
|
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. */
|
/** Moves a layer in the drawing order; the last layer in the list is drawn on top. */
|
||||||
export class MoveTilemapLayerCommand implements Command {
|
export class MoveTilemapLayerCommand extends MoveCommand {
|
||||||
readonly label = 'Reorder layers';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly from: number,
|
from: number,
|
||||||
private readonly to: number,
|
to: number,
|
||||||
) {}
|
) {
|
||||||
|
super('Reorder layers', from, to);
|
||||||
execute(project: Project): void {
|
|
||||||
this.move(project, this.from, this.to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
protected move(project: Project, from: number, to: number): void {
|
||||||
this.move(project, this.to, this.from);
|
|
||||||
}
|
|
||||||
|
|
||||||
private move(project: Project, from: number, to: number): void {
|
|
||||||
if (!moveItem(requireTilemap(project, this.assetId).layers, from, to))
|
if (!moveItem(requireTilemap(project, this.assetId).layers, from, to))
|
||||||
throw new CommandError('No such layer');
|
throw new CommandError('No such layer');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vecto
|
|||||||
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
|
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
|
||||||
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||||
import { copyName } from '../util/names';
|
import { copyName } from '../util/names';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError, MoveCommand } from './Command';
|
||||||
import { nextNumberedName } from './naming';
|
import { nextNumberedName } from './naming';
|
||||||
import { requireVectorArt, requireVectorShape } from './require';
|
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. */
|
/** Moves a shape in the drawing order; the last shape in the list is drawn on top. */
|
||||||
export class MoveVectorShapeCommand implements Command {
|
export class MoveVectorShapeCommand extends MoveCommand {
|
||||||
readonly label = 'Reorder shapes';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly from: number,
|
from: number,
|
||||||
private readonly to: number,
|
to: number,
|
||||||
) {}
|
) {
|
||||||
|
super('Reorder shapes', from, to);
|
||||||
execute(project: Project): void {
|
|
||||||
this.move(project, this.from, this.to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
protected move(project: Project, from: number, to: number): void {
|
||||||
this.move(project, this.to, this.from);
|
|
||||||
}
|
|
||||||
|
|
||||||
private move(project: Project, from: number, to: number): void {
|
|
||||||
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
|
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
|
||||||
throw new CommandError('No such shape');
|
throw new CommandError('No such shape');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function fixedPortKeys(def: NodeDef): string[] {
|
|||||||
return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)];
|
return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function declaredOn(
|
function declaredOn(
|
||||||
def: NodeDef,
|
def: NodeDef,
|
||||||
ports: readonly DeclaredPort[],
|
ports: readonly DeclaredPort[],
|
||||||
side: 'inputs' | 'outputs',
|
side: 'inputs' | 'outputs',
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers';
|
import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { PropertyBag } from '../../../model/Properties';
|
||||||
import { asBoolean, asNumber } from '../../values';
|
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';
|
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';
|
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<string, Value>, 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 = {
|
const MISSED = {
|
||||||
hit: false,
|
hit: false,
|
||||||
object: null,
|
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 ' +
|
'"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 ' +
|
'the ray starts inside is not met, and with "Ignore owner" neither is the owner or anything parented ' +
|
||||||
'below it.',
|
'below it.',
|
||||||
inputs: [
|
inputs: [exec(), ...RAY_INPUTS],
|
||||||
exec(),
|
|
||||||
num('fromX', 'from x'),
|
|
||||||
num('fromY', 'from y'),
|
|
||||||
withDefault(num('dirX', 'dir x'), 1),
|
|
||||||
num('dirY', 'dir y'),
|
|
||||||
withDefault(num('length'), 1000),
|
|
||||||
],
|
|
||||||
outputs: [
|
outputs: [
|
||||||
exec(),
|
exec(),
|
||||||
bool('hit'),
|
bool('hit'),
|
||||||
@@ -47,23 +74,10 @@ export const Raycast: NodeDef = {
|
|||||||
num('normalY', 'normal y'),
|
num('normalY', 'normal y'),
|
||||||
num('distance'),
|
num('distance'),
|
||||||
],
|
],
|
||||||
params: [
|
params: [...RAY_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 },
|
|
||||||
],
|
|
||||||
run: (ctx, inputs, params) => {
|
run: (ctx, inputs, params) => {
|
||||||
const hit = ctx.raycast(
|
const ray = rayOf(ctx, inputs, params);
|
||||||
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
|
const hit = ctx.raycast(ray.from, ray.direction, ray.length, ray.options);
|
||||||
{ 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),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!hit) return { next: 'exec', outputs: MISSED };
|
if (!hit) return { next: 'exec', outputs: MISSED };
|
||||||
return {
|
return {
|
||||||
next: 'exec',
|
next: 'exec',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers';
|
|
||||||
import type { RayOutcome } from '../../NodeContext';
|
import type { RayOutcome } from '../../NodeContext';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asBoolean, asNumber, type ValueList } from '../../values';
|
import { asNumber, type ValueList } from '../../values';
|
||||||
import { exec, int, list, num, withDefault } from '../shared';
|
import { exec, int, list, num } from '../shared';
|
||||||
import { RAY_LAYERS_PARAM } from './Raycast';
|
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
|
* 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 ' +
|
'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 ' +
|
'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.',
|
'once per cell the ray goes through. "max hits" above 0 keeps that many of the nearest.',
|
||||||
inputs: [
|
inputs: [exec(), ...RAY_INPUTS, int('maxHits', 0, 'max hits')],
|
||||||
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'),
|
|
||||||
],
|
|
||||||
outputs: [
|
outputs: [
|
||||||
exec(),
|
exec(),
|
||||||
num('count'),
|
num('count'),
|
||||||
@@ -50,24 +41,13 @@ export const RaycastAll: NodeDef = {
|
|||||||
list('normalsY', 'normals y'),
|
list('normalsY', 'normals y'),
|
||||||
list('distances'),
|
list('distances'),
|
||||||
],
|
],
|
||||||
params: [
|
params: [...RAY_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 },
|
|
||||||
],
|
|
||||||
run: (ctx, inputs, params) => {
|
run: (ctx, inputs, params) => {
|
||||||
const hits = ctx.raycastAll(
|
const ray = rayOf(ctx, inputs, params);
|
||||||
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
|
const hits = ctx.raycastAll(ray.from, ray.direction, ray.length, {
|
||||||
{ x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) },
|
...ray.options,
|
||||||
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))),
|
maxHits: Math.max(0, Math.floor(asNumber(inputs.maxHits))),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
return { next: 'exec', outputs: listsOf(hits) };
|
return { next: 'exec', outputs: listsOf(hits) };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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. */
|
/** 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<string, string> {
|
function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
|
||||||
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
|
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** 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);
|
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. */
|
/** 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;
|
if (isStructValue(value)) return value;
|
||||||
return asPrimitive(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
|
* 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.
|
* 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 incoming = asStruct(value);
|
||||||
const merged: Record<string, PropertyValue> = {};
|
const merged: Record<string, PropertyValue> = {};
|
||||||
for (const [field, own] of Object.entries(current))
|
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. */
|
/** `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 (isList(current)) return storedList(asList(value));
|
||||||
if (isStruct(current)) return mergeStruct(current, value);
|
if (isStruct(current)) return mergeStruct(current, value);
|
||||||
if (typeof current === 'number') return asNumber(value);
|
if (typeof current === 'number') return asNumber(value);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { instanceChildId, resolveObjects } from './instance';
|
|||||||
import { findAsset, type Project } from './Project';
|
import { findAsset, type Project } from './Project';
|
||||||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||||||
import type { SceneObject } from './SceneObject';
|
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. */
|
/** `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;
|
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';
|
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. */
|
/** 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 }[] = [
|
export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [
|
||||||
{ value: 'linear', label: 'Linear' },
|
{ value: 'linear', label: 'Linear' },
|
||||||
@@ -155,7 +147,7 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
|
|||||||
const to = Number(b);
|
const to = Number(b);
|
||||||
return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a;
|
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;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export function generatedPictureOf(asset: Asset, project?: Project): GeneratedPi
|
|||||||
return picture.width > 0 && picture.height > 0 ? picture : null;
|
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 {
|
export function generatedKey(assetId: string, revision: string | number): string {
|
||||||
return `${PREFIX}${assetId}@${revision}`;
|
return `${PREFIX}${assetId}@${revision}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
const MAGIC = [0x47, 0x4d, 0x50, 0x58];
|
const MAGIC = [0x47, 0x4d, 0x50, 0x58];
|
||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
export const CEL_HEADER_BYTES = 10;
|
const CEL_HEADER_BYTES = 10;
|
||||||
/** The largest side a canvas may have (answer 3). */
|
/** The largest side a canvas may have (answer 3). */
|
||||||
export const PIXEL_CANVAS_MAX = 512;
|
export const PIXEL_CANVAS_MAX = 512;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function pixelArtState(asset: PixelArtAsset): PixelArtState {
|
|||||||
return cloneState(asset);
|
return cloneState(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cloneState(state: PixelArtState): PixelArtState {
|
function cloneState(state: PixelArtState): PixelArtState {
|
||||||
return {
|
return {
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
@@ -52,7 +52,7 @@ function frameIndex(state: PixelArtState, frameId: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** "Layer n" with the first number no layer uses yet. */
|
/** "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));
|
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}`;
|
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCanvasSide(side: number): boolean {
|
function isCanvasSide(side: number): boolean {
|
||||||
return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX;
|
return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
* files (see `pixelCel.ts`); whoever composes a frame is handed a way to read them.
|
* 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 { 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 { type CelPixels, PIXEL_CANVAS_MAX } from './pixelCel';
|
||||||
import { findAsset, findAssetOf, type Project } from './Project';
|
import { findAssetOf, type Project } from './Project';
|
||||||
import { textOf } from './Properties';
|
import { textOf } from './Properties';
|
||||||
|
|
||||||
export const PALETTE_KEY = 'palette';
|
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. */
|
/** What the picture path is told (#185): the sheet of all frames side by side. */
|
||||||
export function pixelArtPicture(asset: Asset, project?: Project): GeneratedPicture | null {
|
export function pixelArtPicture(asset: Asset, project?: Project): GeneratedPicture | null {
|
||||||
if (asset.type !== 'pixelart' || asset.frames.length === 0) return 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 {
|
export function isPixelArt(asset: Asset | undefined | null): asset is PixelArtAsset {
|
||||||
return asset?.type === 'pixelart';
|
return asset?.type === 'pixelart';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findPixelArt(project: Project, assetId: string): PixelArtAsset | null {
|
|
||||||
const asset = findAsset(project, assetId);
|
|
||||||
return isPixelArt(asset) ? asset : null;
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ export interface NineSlice {
|
|||||||
export const SLICE_SCALE_KEY = 'sliceScale';
|
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. */
|
/** 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);
|
const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1);
|
||||||
return Number.isFinite(value) && value > 0 ? value : 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';
|
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||||||
|
|
||||||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
/** 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. */
|
/** The frame's collision outlines (#141), a copy; empty when it has none. */
|
||||||
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
||||||
@@ -158,12 +158,12 @@ export function frameFill(frame: TextureFrame): FrameFill {
|
|||||||
return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL;
|
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;
|
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. */
|
/** 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);
|
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
|
||||||
if (!hasBorders(border)) return null;
|
if (!hasBorders(border)) return null;
|
||||||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function alphaOfRect(
|
|||||||
return alpha;
|
return alpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampPoints(value: number): number {
|
function clampPoints(value: number): number {
|
||||||
const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints);
|
const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints);
|
||||||
return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n));
|
return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight
|
* 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
|
* pieces, so a curve has to be cut into them – and on its own the result would be corners only, even
|
||||||
* nothing had crossed the curve at all.
|
* 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
|
* 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
|
* 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 };
|
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 u = 1 - t;
|
||||||
const a = u * u * u;
|
const a = u * u * u;
|
||||||
const b = 3 * u * u * t;
|
const b = 3 * u * u * t;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isGeneratedFile } from './generated';
|
|||||||
import type { Project } from './Project';
|
import type { Project } from './Project';
|
||||||
import type { Vec2 } from './Properties';
|
import type { Vec2 } from './Properties';
|
||||||
import { resolvePicture, type TextureRef } from './texture';
|
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. */
|
/** What the fill of a shape is made of; `solid` is the shape's own `fill` colour. */
|
||||||
export type FillKind = 'solid' | 'linear' | 'radial' | 'texture';
|
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 };
|
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. */
|
/** 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 {
|
export function withStopAdded(gradient: VectorGradient, offset: number): VectorGradient {
|
||||||
const at = clamp01(offset);
|
const at = clamp01(offset);
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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 from = { x: paint.from.x, y: paint.from.y };
|
||||||
const to = { x: paint.to.x, y: paint.to.y };
|
const to = { x: paint.to.x, y: paint.to.y };
|
||||||
if (paint.type === 'texture') {
|
if (paint.type === 'texture') {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* lies outside the canvas is cut off (answer 3) – otherwise a sprite's size would be a lie.
|
* 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 { 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 { Project } from './Project';
|
||||||
import type { PropertyBag, Vec2 } from './Properties';
|
import type { PropertyBag, Vec2 } from './Properties';
|
||||||
import { paintText, textureFillText } from './vectorPaint';
|
import { paintText, textureFillText } from './vectorPaint';
|
||||||
@@ -45,10 +45,6 @@ export function isDrawableRing(ring: VectorRing): boolean {
|
|||||||
return ring.points.length >= (ring.closed ? 3 : 2);
|
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. */
|
/** 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 {
|
export function shapeExtent(shape: VectorShape): { x0: number; y0: number; x1: number; y1: number } | null {
|
||||||
let x0 = Infinity;
|
let x0 = Infinity;
|
||||||
@@ -114,16 +110,6 @@ function handleText(handle: Vec2 | undefined): string {
|
|||||||
return handle ? `${handle.x},${handle.y}` : '-';
|
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
|
* 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
|
* still has a canvas: an empty picture is what an empty drawing looks like, and a sprite wearing it
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
|
|||||||
import { propertyNodeParams } from '../properties/declaredRows';
|
import { propertyNodeParams } from '../properties/declaredRows';
|
||||||
import { flatDef } from '../properties/PropertySchema';
|
import { flatDef } from '../properties/PropertySchema';
|
||||||
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
|
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. */
|
/** What one particle is, at one moment: everything a graph may read about it. */
|
||||||
export interface ParticleEnv {
|
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 = {
|
export const ParticleStep: ParticleNodeDef = {
|
||||||
type: 'ParticleStep',
|
type: 'ParticleStep',
|
||||||
label: 'Step',
|
label: 'Step',
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ import { defaultsFor } from './PropertySchema';
|
|||||||
const TEXT_DEFAULTS = defaultsFor('text');
|
const TEXT_DEFAULTS = defaultsFor('text');
|
||||||
|
|
||||||
/** Symbol sizes in world units. */
|
/** Symbol sizes in world units. */
|
||||||
export const BEHAVIOR_SYMBOL = 28;
|
const BEHAVIOR_SYMBOL = 28;
|
||||||
export const CONTAINER_SYMBOL = 24;
|
const CONTAINER_SYMBOL = 24;
|
||||||
export const PARTICLES_SYMBOL = 24;
|
const PARTICLES_SYMBOL = 24;
|
||||||
export const RIGIDBODY_SYMBOL = 24;
|
const RIGIDBODY_SYMBOL = 24;
|
||||||
|
|
||||||
/** Rough width of one character relative to the font size, for a plain sans. */
|
/** Rough width of one character relative to the font size, for a plain sans. */
|
||||||
const CHAR_WIDTH = 0.55;
|
const CHAR_WIDTH = 0.55;
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export function outlinesOf(shape: Polygon): Vec2[][] {
|
|||||||
/** Where a polygon collider keeps its corners (#151). */
|
/** Where a polygon collider keeps its corners (#151). */
|
||||||
export const COLLIDER_POINTS_KEY = 'points';
|
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. */
|
/** 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. */
|
/** 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';
|
return props.shape === 'frame';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -124,6 +124,12 @@ export abstract class AssetTabEditor<
|
|||||||
this.canvas.restoreView?.(view);
|
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`. */
|
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||||||
protected syncExtras(_asset: A | null): void {}
|
protected syncExtras(_asset: A | null): void {}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCan
|
|||||||
return this.canvas.objectScreen(objectId);
|
return this.canvas.objectScreen(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The drawing area without the header, so a check can ask whether a point is on screen. */
|
|
||||||
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 {
|
override selectedId(): string {
|
||||||
return this.state.selectedContainerId ?? '';
|
return this.state.selectedContainerId ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import { collapsedNodeWidth } from './folding';
|
|||||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||||
export const NODE_WIDTH = 176;
|
export const NODE_WIDTH = 176;
|
||||||
export const TITLE_HEIGHT = 24;
|
export const TITLE_HEIGHT = 24;
|
||||||
export const ROW_HEIGHT = 20;
|
const ROW_HEIGHT = 20;
|
||||||
export const PORT_RADIUS = 5;
|
const PORT_RADIUS = 5;
|
||||||
/** Distance (graph units) within which a press counts as hitting a port. */
|
/** Distance (graph units) within which a press counts as hitting a port. */
|
||||||
export const PORT_HIT_RADIUS = 9;
|
const PORT_HIT_RADIUS = 9;
|
||||||
const PAD = 6;
|
const PAD = 6;
|
||||||
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
|
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
|
||||||
const CHEVRON_SLOT = 14;
|
const CHEVRON_SLOT = 14;
|
||||||
@@ -37,7 +37,7 @@ const TITLE_X = PAD + CHEVRON_SLOT;
|
|||||||
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
||||||
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
||||||
|
|
||||||
export function categoryColor(category: NodeCategory): number {
|
function categoryColor(category: NodeCategory): number {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'event':
|
case 'event':
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export class SnapToggle {
|
|||||||
return [this.button];
|
return [this.button];
|
||||||
}
|
}
|
||||||
|
|
||||||
get snapping(): boolean {
|
|
||||||
return this.on;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(on: boolean): void {
|
set(on: boolean): void {
|
||||||
this.on = on;
|
this.on = on;
|
||||||
this.target().setSnapping(on);
|
this.target().setSnapping(on);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type FederatedWheelEvent,
|
type FederatedWheelEvent,
|
||||||
Graphics,
|
Graphics,
|
||||||
Sprite,
|
Sprite,
|
||||||
Text,
|
|
||||||
Texture,
|
Texture,
|
||||||
} from 'pixi.js';
|
} from 'pixi.js';
|
||||||
import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands';
|
import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands';
|
||||||
@@ -41,11 +40,12 @@ import {
|
|||||||
} from '../../core/model/pixelDraw';
|
} from '../../core/model/pixelDraw';
|
||||||
import { withCels } from '../../core/model/pixelEdit';
|
import { withCels } from '../../core/model/pixelEdit';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { cancelFrameWork, onFrame, type Size, Theme, textStyle, ui, Widget } from '../../ui';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
|
import { cancelFrameWork, onFrame, Theme, type Widget } from '../../ui';
|
||||||
import { pixelCels } from '../../viewport/PixelCels';
|
import { pixelCels } from '../../viewport/PixelCels';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { drawChecker } from '../texture/checker';
|
import { drawChecker } from '../texture/checker';
|
||||||
import type { SheetView } from '../texture/sheetView';
|
import { SheetCanvas } from '../texture/SheetCanvas';
|
||||||
import { centredView, fitZoom, GRID_FROM_ZOOM, pixelUnder, stepZoom } from './pixelView';
|
import { centredView, fitZoom, GRID_FROM_ZOOM, pixelUnder, stepZoom } from './pixelView';
|
||||||
|
|
||||||
export type PixelTool = 'pencil' | 'eraser' | 'fill' | 'picker' | 'line' | 'rect' | 'ellipse';
|
export type PixelTool = 'pencil' | 'eraser' | 'fill' | 'picker' | 'line' | 'rect' | 'ellipse';
|
||||||
@@ -92,22 +92,13 @@ interface FramePicture {
|
|||||||
texture: Texture;
|
texture: Texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PixelArtCanvas extends Widget {
|
export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
|
||||||
private readonly bg = new Graphics();
|
|
||||||
private readonly content = new Container();
|
|
||||||
private readonly checker = new Graphics();
|
|
||||||
private readonly onion = new Container();
|
private readonly onion = new Container();
|
||||||
private readonly tiles = new Container();
|
private readonly tiles = new Container();
|
||||||
private readonly art = new Sprite();
|
private readonly art = new Sprite();
|
||||||
private readonly grid = new Graphics();
|
private readonly grid = new Graphics();
|
||||||
private readonly overlay = 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<string, FramePicture>();
|
private readonly pictures = new Map<string, FramePicture>();
|
||||||
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 stroke: Stroke | null = null;
|
||||||
private hover: PixelPoint | null = null;
|
private hover: PixelPoint | null = null;
|
||||||
/** Which frame and layer is being painted, per asset, so a tab switch comes back to them. */
|
/** 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) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.content.addChild(this.checker, this.onion, this.tiles, this.art, this.grid, this.overlay);
|
this.content.addChild(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.on('pointerleave', () => {
|
this.on('pointerleave', () => {
|
||||||
this.hover = null;
|
this.hover = null;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
});
|
});
|
||||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
|
||||||
state.on('project', () => this.redraw());
|
state.on('project', () => this.redraw());
|
||||||
state.on('load', () => {
|
state.on('load', () => {
|
||||||
this.places.clear();
|
this.places.clear();
|
||||||
@@ -161,10 +141,6 @@ export class PixelArtCanvas extends Widget {
|
|||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
|
||||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
|
||||||
}
|
|
||||||
|
|
||||||
show(assetId: string): void {
|
show(assetId: string): void {
|
||||||
if (this.assetId === assetId) return;
|
if (this.assetId === assetId) return;
|
||||||
this.cancelStroke();
|
this.cancelStroke();
|
||||||
@@ -174,27 +150,6 @@ export class PixelArtCanvas extends Widget {
|
|||||||
this.onChange();
|
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 {
|
get zoom(): number {
|
||||||
return this.view.zoom;
|
return this.view.zoom;
|
||||||
}
|
}
|
||||||
@@ -268,17 +223,11 @@ export class PixelArtCanvas extends Widget {
|
|||||||
return cel === null ? emptyCel(asset.width, asset.height) : cel;
|
return cel === null ? emptyCel(asset.width, asset.height) : cel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerDown(e: FederatedPointerEvent): void {
|
protected pressTarget(): PixelArtAsset | null {
|
||||||
const asset = this.asset();
|
return 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 press(asset: PixelArtAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const at = pixelUnder(this.view, local.x, local.y);
|
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.
|
// 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);
|
this.setColor(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerMove(e: FederatedPointerEvent): void {
|
protected override panTo(panX: number, panY: number): void {
|
||||||
const local = this.toLocal(e.global);
|
this.view = { ...this.view, panX: Math.round(panX), panY: Math.round(panY) };
|
||||||
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.needsFit = false;
|
||||||
this.redraw();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected move(local: Vec2): void {
|
||||||
const at = pixelUnder(this.view, local.x, local.y);
|
const at = pixelUnder(this.view, local.x, local.y);
|
||||||
const stroke = this.stroke;
|
const stroke = this.stroke;
|
||||||
if (stroke) {
|
if (stroke) {
|
||||||
@@ -380,8 +323,7 @@ export class PixelArtCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerUp(): void {
|
protected release(): void {
|
||||||
this.panning = null;
|
|
||||||
const stroke = this.stroke;
|
const stroke = this.stroke;
|
||||||
this.stroke = null;
|
this.stroke = null;
|
||||||
cancelFrameWork(this.frameKey);
|
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 local = this.toLocal(e.global);
|
||||||
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
|
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
|
||||||
if (zoom !== this.view.zoom) {
|
if (zoom !== this.view.zoom) {
|
||||||
@@ -449,8 +391,7 @@ export class PixelArtCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
this.drawGround();
|
||||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
|
||||||
this.checker.clear();
|
this.checker.clear();
|
||||||
this.grid.clear();
|
this.grid.clear();
|
||||||
this.overlay.clear();
|
this.overlay.clear();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { EditorState } from '../state';
|
|||||||
import { drawChecker } from '../texture/checker';
|
import { drawChecker } from '../texture/checker';
|
||||||
|
|
||||||
const HEIGHT = 96;
|
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
|
* The frames of a pixel art asset playing in a loop (answer 5), small, as the picture path draws them
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { SheetView } from '../texture/sheetView';
|
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. */
|
/** 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. */
|
/** 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;
|
export const GRID_FROM_ZOOM = 8;
|
||||||
|
|
||||||
|
|||||||
@@ -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<A> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js';
|
||||||
Container,
|
|
||||||
type FederatedPointerEvent,
|
|
||||||
type FederatedWheelEvent,
|
|
||||||
Graphics,
|
|
||||||
Sprite,
|
|
||||||
Text,
|
|
||||||
} from 'pixi.js';
|
|
||||||
import {
|
import {
|
||||||
AddFrameCommand,
|
AddFrameCommand,
|
||||||
SetFrameCollisionCommand,
|
SetFrameCollisionCommand,
|
||||||
@@ -28,7 +21,7 @@ import {
|
|||||||
setFrameCollision,
|
setFrameCollision,
|
||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
} from '../../core/model/texture';
|
} from '../../core/model/texture';
|
||||||
import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
||||||
import {
|
import {
|
||||||
gripAt,
|
gripAt,
|
||||||
insideOutline,
|
insideOutline,
|
||||||
@@ -42,6 +35,7 @@ import { dashedLine } from '../../viewport/symbols';
|
|||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { drawChecker } from './checker';
|
import { drawChecker } from './checker';
|
||||||
|
import { SheetCanvas } from './SheetCanvas';
|
||||||
import {
|
import {
|
||||||
borderAt,
|
borderAt,
|
||||||
borderCursor,
|
borderCursor,
|
||||||
@@ -59,8 +53,6 @@ import {
|
|||||||
imageToLocal,
|
imageToLocal,
|
||||||
localToImage,
|
localToImage,
|
||||||
rectBetween,
|
rectBetween,
|
||||||
type SheetView,
|
|
||||||
zoomAbout,
|
|
||||||
} from './sheetView';
|
} from './sheetView';
|
||||||
|
|
||||||
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
|
/** 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
|
* 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.
|
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
|
||||||
*/
|
*/
|
||||||
export class TextureCanvas extends Widget {
|
export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||||
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();
|
|
||||||
private readonly image = new Sprite();
|
private readonly image = new Sprite();
|
||||||
private readonly overlay = new Graphics();
|
private readonly overlay = new Graphics();
|
||||||
private readonly labels = new Map<string, Text>();
|
private readonly labels = new Map<string, Text>();
|
||||||
private readonly labelLayer = new Container();
|
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 dragging: FrameDrag | null = null;
|
||||||
private spanning: NewFrame | null = null;
|
private spanning: NewFrame | null = null;
|
||||||
private corner: CornerDrag | null = null;
|
private corner: CornerDrag | null = null;
|
||||||
@@ -140,18 +121,7 @@ export class TextureCanvas extends Widget {
|
|||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.content.addChild(this.checker, this.image, this.overlay, this.labelLayer);
|
this.content.addChild(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));
|
|
||||||
|
|
||||||
state.on('project', () => this.redraw());
|
state.on('project', () => this.redraw());
|
||||||
state.on('selection', () => this.redraw());
|
state.on('selection', () => this.redraw());
|
||||||
@@ -169,10 +139,6 @@ export class TextureCanvas extends Widget {
|
|||||||
super.destroy(options);
|
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. */
|
/** Switching resets the view, since the next picture is a new subject. */
|
||||||
show(assetId: string): void {
|
show(assetId: string): void {
|
||||||
if (this.assetId === assetId) return;
|
if (this.assetId === assetId) return;
|
||||||
@@ -181,22 +147,6 @@ export class TextureCanvas extends Widget {
|
|||||||
this.redraw();
|
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 {
|
setCollisionMode(on: boolean): void {
|
||||||
if (this.collision === on) return;
|
if (this.collision === on) return;
|
||||||
this.collision = on;
|
this.collision = on;
|
||||||
@@ -207,18 +157,6 @@ export class TextureCanvas extends Widget {
|
|||||||
return this.collision;
|
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 {
|
private asset(): TextureAsset | null {
|
||||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||||
return asset && asset.type === 'texture' ? asset : null;
|
return asset && asset.type === 'texture' ? asset : null;
|
||||||
@@ -259,19 +197,16 @@ export class TextureCanvas extends Widget {
|
|||||||
return true;
|
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();
|
const asset = this.asset();
|
||||||
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
|
return asset && asset.width > 0 && asset.height > 0 ? asset : null;
|
||||||
// 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);
|
protected press(asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||||||
const point = localToImage(this.view, local.x, local.y);
|
const point = localToImage(this.view, local.x, local.y);
|
||||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||||
if (this.collision) {
|
if (this.collision) {
|
||||||
@@ -430,17 +365,7 @@ export class TextureCanvas extends Widget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerMove(e: FederatedPointerEvent): void {
|
protected move(local: Vec2): 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;
|
|
||||||
}
|
|
||||||
if (this.corner) {
|
if (this.corner) {
|
||||||
const corner = this.corner;
|
const corner = this.corner;
|
||||||
const frame = this.frame(corner.frameId);
|
const frame = this.frame(corner.frameId);
|
||||||
@@ -502,8 +427,7 @@ export class TextureCanvas extends Widget {
|
|||||||
this.state.preview();
|
this.state.preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerUp(): void {
|
protected release(): void {
|
||||||
this.panning = null;
|
|
||||||
const corner = this.corner;
|
const corner = this.corner;
|
||||||
this.corner = null;
|
this.corner = null;
|
||||||
const frame = corner && this.frame(corner.frameId);
|
const frame = corner && this.frame(corner.frameId);
|
||||||
@@ -553,14 +477,6 @@ export class TextureCanvas extends Widget {
|
|||||||
this.redraw();
|
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 {
|
private updateCursor(local: Vec2): void {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -594,8 +510,7 @@ export class TextureCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
this.drawGround();
|
||||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
const texture = asset && asset.file ? this.pictureOf(asset) : null;
|
const texture = asset && asset.file ? this.pictureOf(asset) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
|||||||
return this.canvas.imageToScreen(x, y);
|
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 {
|
override selectedId(): string {
|
||||||
return this.state.selectedTextureId ?? '';
|
return this.state.selectedTextureId ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* directly as a preview and puts it back on release, so one drag is one undo step – the same way
|
* 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.
|
* 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 {
|
import {
|
||||||
AddVectorShapeCommand,
|
AddVectorShapeCommand,
|
||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
@@ -33,17 +33,7 @@ import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
|||||||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||||
import {
|
import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui';
|
||||||
cancelFrameWork,
|
|
||||||
onFrame,
|
|
||||||
pressCount,
|
|
||||||
type Size,
|
|
||||||
Theme,
|
|
||||||
textStyle,
|
|
||||||
ui,
|
|
||||||
Widget,
|
|
||||||
zoomFactor,
|
|
||||||
} from '../../ui';
|
|
||||||
import {
|
import {
|
||||||
insideRing,
|
insideRing,
|
||||||
type PointKind,
|
type PointKind,
|
||||||
@@ -61,7 +51,8 @@ import {
|
|||||||
} from '../polygonEdit';
|
} from '../polygonEdit';
|
||||||
import { type EditorState, shapeSelection } from '../state';
|
import { type EditorState, shapeSelection } from '../state';
|
||||||
import { drawChecker } from '../texture/checker';
|
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
|
* 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. */
|
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||||
const DRAG_THRESHOLD = 3;
|
const DRAG_THRESHOLD = 3;
|
||||||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
/** 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. */
|
/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */
|
||||||
interface ShapeDrag {
|
interface ShapeDrag {
|
||||||
@@ -115,21 +106,11 @@ interface PaintDrag {
|
|||||||
/** Counts canvases so each gets its own key in the frame queue. */
|
/** Counts canvases so each gets its own key in the frame queue. */
|
||||||
let instances = 0;
|
let instances = 0;
|
||||||
|
|
||||||
export class VectorArtCanvas extends Widget {
|
export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||||
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();
|
|
||||||
private readonly art = new Graphics();
|
private readonly art = new Graphics();
|
||||||
private readonly overlay = 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 tool: VectorTool = 'select';
|
||||||
private snap = false;
|
private snap = false;
|
||||||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
|
||||||
private drag: ShapeDrag | null = null;
|
private drag: ShapeDrag | null = null;
|
||||||
private paintDrag: PaintDrag | null = null;
|
private paintDrag: PaintDrag | null = null;
|
||||||
private target: PaintTarget = 'fill';
|
private target: PaintTarget = 'fill';
|
||||||
@@ -147,18 +128,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.content.addChild(this.checker, this.art, this.overlay);
|
this.content.addChild(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));
|
|
||||||
|
|
||||||
state.on('project', () => this.redraw());
|
state.on('project', () => this.redraw());
|
||||||
state.on('selection', () => {
|
state.on('selection', () => {
|
||||||
@@ -181,10 +151,6 @@ export class VectorArtCanvas extends Widget {
|
|||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
|
||||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
|
||||||
}
|
|
||||||
|
|
||||||
show(assetId: string): void {
|
show(assetId: string): void {
|
||||||
if (this.assetId === assetId) return;
|
if (this.assetId === assetId) return;
|
||||||
this.assetId = assetId;
|
this.assetId = assetId;
|
||||||
@@ -194,27 +160,6 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.redraw();
|
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 {
|
setTool(tool: VectorTool): void {
|
||||||
if (this.tool === tool) return;
|
if (this.tool === tool) return;
|
||||||
this.tool = tool;
|
this.tool = tool;
|
||||||
@@ -285,13 +230,6 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.onPointPicked();
|
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 {
|
private asset(): VectorArtAsset | null {
|
||||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||||
return asset && asset.type === 'vectorart' ? asset : null;
|
return asset && asset.type === 'vectorart' ? asset : null;
|
||||||
@@ -332,17 +270,11 @@ export class VectorArtCanvas extends Widget {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerDown(e: FederatedPointerEvent): void {
|
protected pressTarget(): VectorArtAsset | null {
|
||||||
const asset = this.asset();
|
return 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 press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||||||
const at = localToImage(this.view, local.x, local.y);
|
const at = localToImage(this.view, local.x, local.y);
|
||||||
const tolerance = POINT_SIZE / this.view.zoom;
|
const tolerance = POINT_SIZE / this.view.zoom;
|
||||||
if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance);
|
if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance);
|
||||||
@@ -556,17 +488,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
else this.redraw();
|
else this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerMove(e: FederatedPointerEvent): void {
|
protected move(local: Vec2): 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;
|
|
||||||
}
|
|
||||||
if (this.paintDrag) {
|
if (this.paintDrag) {
|
||||||
this.movePaint(this.paintDrag, local);
|
this.movePaint(this.paintDrag, local);
|
||||||
return;
|
return;
|
||||||
@@ -650,8 +572,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.state.preview();
|
this.state.preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerUp(): void {
|
protected release(): void {
|
||||||
this.panning = null;
|
|
||||||
if (this.paintDrag) {
|
if (this.paintDrag) {
|
||||||
this.releasePaint(this.paintDrag);
|
this.releasePaint(this.paintDrag);
|
||||||
return;
|
return;
|
||||||
@@ -708,14 +629,6 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle'));
|
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 {
|
private updateCursor(local: Vec2): void {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -772,8 +685,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
this.drawGround();
|
||||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
const size = asset ? vectorCanvasSize(asset.props) : null;
|
const size = asset ? vectorCanvasSize(asset.props) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const INFO_WIDTH = 190;
|
|||||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
||||||
const POINT_WIDTH = 150;
|
const POINT_WIDTH = 150;
|
||||||
|
|
||||||
export const POINT_KIND_LABELS: Record<PointKind, string> = {
|
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||||
straight: 'Straight',
|
straight: 'Straight',
|
||||||
corner: 'Corner',
|
corner: 'Corner',
|
||||||
smooth: 'Smooth',
|
smooth: 'Smooth',
|
||||||
@@ -112,13 +112,7 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
|
|
||||||
/** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */
|
/** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */
|
||||||
canvasToScreen(x: number, y: number): { x: number; y: number } {
|
canvasToScreen(x: number, y: number): { x: number; y: number } {
|
||||||
return this.canvas.canvasToScreen(x, y);
|
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 {
|
override selectedId(): string {
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ export abstract class WorldViewport extends Widget {
|
|||||||
/**
|
/**
|
||||||
* The camera moved and nothing else did – a pan. Zoom and size are the same, so every node is
|
* The camera moved and nothing else did – a pan. Zoom and size are the same, so every node is
|
||||||
* drawn as it was; only the world's position and the screen-space overlays follow. Anything
|
* drawn as it was; only the world's position and the screen-space overlays follow. Anything
|
||||||
* else, or an object crossing the edge of the view, takes the full redraw: a pan frame used to
|
* else, or an object crossing the edge of the view, takes the full redraw, which would collect,
|
||||||
* collect, redraw and re-upload every shape for nothing.
|
* redraw and re-upload every shape of a pan frame for nothing.
|
||||||
*/
|
*/
|
||||||
protected moveCamera(): void {
|
protected moveCamera(): void {
|
||||||
const view = this.currentView();
|
const view = this.currentView();
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export interface EventSources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
|
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
|
||||||
export function runsWhilePaused(props: PropertyBag): boolean {
|
function runsWhilePaused(props: PropertyBag): boolean {
|
||||||
return props.runWhilePaused === true;
|
return props.runWhilePaused === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ export function blockingCollidersOf(world: World, entity: Entity): Entity[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `body` (a collider or a tilemap) stands in the way of `mover`. */
|
/** Whether `body` (a collider or a tilemap) stands in the way of `mover`. */
|
||||||
export function blocksMover(body: Entity, mover: Entity): boolean {
|
function blocksMover(body: Entity, mover: Entity): boolean {
|
||||||
return (
|
return (
|
||||||
isCollisionBody(body) &&
|
isCollisionBody(body) &&
|
||||||
!(body.type === 'collider' && isTrigger(body.props)) &&
|
!(body.type === 'collider' && isTrigger(body.props)) &&
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ export class GeneratedPictures<T> {
|
|||||||
return this.kept.get(key)?.picture ?? null;
|
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. */
|
/** The keys held, in the order they were drawn – what a test reads to see a revision go. */
|
||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return [...this.kept.keys()];
|
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;
|
return generators.get(type) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** 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;
|
if (asset.type !== 'pixelart' || asset.frames.length === 0) return null;
|
||||||
const rgba = composeSheet(asset, paletteColors(project, asset), (file) => pixelCels.read(file));
|
const rgba = composeSheet(asset, paletteColors(project, asset), (file) => pixelCels.read(file));
|
||||||
if (!rgba) return null;
|
if (!rgba) return null;
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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. */
|
/** Registers vector art in both halves of the picture path; the returned function takes it out. */
|
||||||
export function installVectorArtPictures(renderer: Renderer): () => void {
|
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,
|
* 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.
|
* 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);
|
const canvas = vectorArtCanvas(asset, asset.props, project);
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
const graphics = new Graphics();
|
const graphics = new Graphics();
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
liveRange,
|
liveRange,
|
||||||
prewarmOffset,
|
prewarmOffset,
|
||||||
} from '../../src/core/particles/emission';
|
} from '../../src/core/particles/emission';
|
||||||
|
import { mixColor } from '../../src/core/util/color';
|
||||||
import {
|
import {
|
||||||
hashId,
|
hashId,
|
||||||
mixColor,
|
|
||||||
PARTICLE_NODES,
|
PARTICLE_NODES,
|
||||||
PARTICLE_OUTPUT,
|
PARTICLE_OUTPUT,
|
||||||
PARTICLE_OUTPUTS,
|
PARTICLE_OUTPUTS,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { findAsset } from '../../src/core/model/Project';
|
|||||||
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
|
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||||
import { colliderShape, type ColliderShape } from '../../src/core/properties/collider';
|
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 { rayCast } from '../../src/core/properties/ray';
|
||||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||||
import { RAY_SHOWN } from '../../src/runtime/World';
|
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]);
|
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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,6 @@ import { resolvePicture } from '../../src/core/model/texture';
|
|||||||
import {
|
import {
|
||||||
handleAt,
|
handleAt,
|
||||||
isDrawableRing,
|
isDrawableRing,
|
||||||
isDrawableShape,
|
|
||||||
SHAPE_FILL_DEFAULT,
|
SHAPE_FILL_DEFAULT,
|
||||||
shapeExtent,
|
shapeExtent,
|
||||||
shapeOrderTarget,
|
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, 2) })).toBe(false);
|
||||||
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
|
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(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', () => {
|
it('measures a shape including the handles pulled off its points', () => {
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ describe('SnapToggle', () => {
|
|||||||
it('starts off and switches the target on and off again, lit while on', () => {
|
it('starts off and switches the target on and off again, lit while on', () => {
|
||||||
const canvas = target();
|
const canvas = target();
|
||||||
const snap = new SnapToggle(() => canvas);
|
const snap = new SnapToggle(() => canvas);
|
||||||
expect(snap.snapping).toBe(false);
|
|
||||||
expect(snap.button.toggled).toBe(false);
|
expect(snap.button.toggled).toBe(false);
|
||||||
|
|
||||||
snap.button.onClick?.();
|
snap.button.onClick?.();
|
||||||
expect(snap.snapping).toBe(true);
|
|
||||||
expect(snap.button.toggled).toBe(true);
|
expect(snap.button.toggled).toBe(true);
|
||||||
|
|
||||||
snap.button.onClick?.();
|
snap.button.onClick?.();
|
||||||
expect(snap.snapping).toBe(false);
|
|
||||||
expect(snap.button.toggled).toBe(false);
|
expect(snap.button.toggled).toBe(false);
|
||||||
expect(canvas.calls).toEqual([true, false]);
|
expect(canvas.calls).toEqual([true, false]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('generated pictures kept in memory', () => {
|
|||||||
const f = library();
|
const f = library();
|
||||||
f.answerWith(() => null);
|
f.answerWith(() => null);
|
||||||
expect(f.pictures.get('gen:a@1')).toBeNull();
|
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');
|
f.answerWith(() => 'ready');
|
||||||
expect(f.pictures.get('gen:a@1')).toBe('ready');
|
expect(f.pictures.get('gen:a@1')).toBe('ready');
|
||||||
expect(f.drawn).toEqual(['a', 'a']);
|
expect(f.drawn).toEqual(['a', 'a']);
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export async function frames(t) {
|
|||||||
(await json('__gm.textOverflows()')).length === 0,
|
(await json('__gm.textOverflows()')).length === 0,
|
||||||
JSON.stringify(await json('__gm.textOverflows()')),
|
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');
|
await clickText('Fit');
|
||||||
|
|
||||||
// 13g. A sprite pointed at a frame slicing removed falls back to its colour.
|
// 13g. A sprite pointed at a frame slicing removed falls back to its colour.
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export async function vectorArt(t) {
|
|||||||
JSON.stringify(moved),
|
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.
|
// 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()');
|
const magnet = await json('__gm.vectorArt.snapButton()');
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -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.
|
// 48b. The pencil: a swatch picks the colour, a drag paints a row, and one undo takes the stroke back.
|
||||||
await swatch(RED);
|
await swatch(RED);
|
||||||
await press_('pencil');
|
await press_('pencil');
|
||||||
|
|||||||
Reference in new issue
Block a user