Refactoring round 7: split BehaviorSystem, fewer copies (#85)
Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dded62b9f9
commit
bc688f3efc
93 files changed
+914
-902
No files matched your search
@@ -14,9 +14,10 @@ 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, variables, struct,
|
||||||
container, texture, vector art, pixel art, palette, font, sound, animation, state machine, shader,
|
collider, rigidbody, container, clip area, texture, vector art, pixel art, palette, animation,
|
||||||
particles, variables, tile, tilemap) with properties and no position.
|
curve, tile, tilemap, state machine, shader, font, sound, particles) 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
|
||||||
@@ -27,7 +28,8 @@ way in.
|
|||||||
account sees only its own projects. The Account menu at the far right of the toolbar changes
|
account sees only its own projects. The Account menu at the far right of the toolbar changes
|
||||||
the password and signs out.
|
the password and signs out.
|
||||||
- **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back
|
- **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back
|
||||||
exactly what was saved.
|
exactly what was saved. **Release…** shares a link that plays the saved game on its own, without
|
||||||
|
the editor and without signing in.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
|||||||
import { isSoundFile } from '../model/sound';
|
import { isSoundFile } from '../model/sound';
|
||||||
import { removeSubtrees } from '../model/hierarchy';
|
import { removeSubtrees } from '../model/hierarchy';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import { findAssetOf, objectHolders, type Project } from '../model/Project';
|
import { objectHolders, type Project } from '../model/Project';
|
||||||
import {
|
import {
|
||||||
type Scope,
|
type Scope,
|
||||||
scopeOf,
|
scopeOf,
|
||||||
@@ -29,7 +29,7 @@ import { isTextureFile } from '../model/texture';
|
|||||||
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
||||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
import { requireAsset, requirePropDef, requireStateMachine } from './require';
|
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
|
||||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||||
|
|
||||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||||
@@ -314,19 +314,13 @@ export class SetVariablesPersistentCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = this.target(project);
|
const asset = requireVariablesAsset(project, this.assetId);
|
||||||
this.previous = asset.persistent === true;
|
this.previous = asset.persistent === true;
|
||||||
setPersistent(asset, this.persistent);
|
setPersistent(asset, this.persistent);
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
setPersistent(this.target(project), this.previous);
|
setPersistent(requireVariablesAsset(project, this.assetId), this.previous);
|
||||||
}
|
|
||||||
|
|
||||||
private target(project: Project): VariablesAsset {
|
|
||||||
const asset = findAssetOf(project, this.assetId, 'variables');
|
|
||||||
if (!asset) throw new CommandError(`Variables ${this.assetId} not found`);
|
|
||||||
return asset;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +347,7 @@ export class SetVariablesScopeCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = this.target(project);
|
const asset = requireVariablesAsset(project, this.assetId);
|
||||||
this.previous = sharedScopeOf(asset);
|
this.previous = sharedScopeOf(asset);
|
||||||
this.wasPersistent = asset.persistent === true;
|
this.wasPersistent = asset.persistent === true;
|
||||||
setSharedScope(asset, this.scope);
|
setSharedScope(asset, this.scope);
|
||||||
@@ -361,16 +355,10 @@ export class SetVariablesScopeCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
const asset = this.target(project);
|
const asset = requireVariablesAsset(project, this.assetId);
|
||||||
setSharedScope(asset, this.previous);
|
setSharedScope(asset, this.previous);
|
||||||
setPersistent(asset, this.wasPersistent);
|
setPersistent(asset, this.wasPersistent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private target(project: Project): VariablesAsset {
|
|
||||||
const asset = findAssetOf(project, this.assetId, 'variables');
|
|
||||||
if (!asset) throw new CommandError(`Variables ${this.assetId} not found`);
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
TilemapAsset,
|
TilemapAsset,
|
||||||
TilemapLayer,
|
TilemapLayer,
|
||||||
VariableOwnerAsset,
|
VariableOwnerAsset,
|
||||||
|
VariablesAsset,
|
||||||
VectorArtAsset,
|
VectorArtAsset,
|
||||||
VectorShape,
|
VectorShape,
|
||||||
} from '../model/Asset';
|
} from '../model/Asset';
|
||||||
@@ -131,6 +132,12 @@ export function requireCurve(project: Project, assetId: string): CurveAsset {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
|
||||||
|
const asset = findAssetOf(project, assetId, 'variables');
|
||||||
|
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
||||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||||
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
||||||
|
|||||||
@@ -197,6 +197,18 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A new Struct asset declaring `fields`, each at the value `values` holds for it, named after `name`. */
|
||||||
|
function structAssetFrom(
|
||||||
|
project: Project,
|
||||||
|
fields: readonly StructFieldDef[],
|
||||||
|
values: Readonly<Record<string, PropertyValue>>,
|
||||||
|
name: string,
|
||||||
|
): StructAsset {
|
||||||
|
const variables = fields.map((f) => variableOfField(f, values[f.key]));
|
||||||
|
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
|
||||||
|
return { id: newId(), type: 'struct', name: nextAssetName(project, name), props, variables };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns the fields a struct holds by itself into a Struct asset, and types the struct with it. The
|
* Turns the fields a struct holds by itself into a Struct asset, and types the struct with it. The
|
||||||
* value it holds now becomes the asset's defaults, so the place reads exactly as before.
|
* value it holds now becomes the asset's defaults, so the place reads exactly as before.
|
||||||
@@ -212,11 +224,6 @@ export class MakeStructTypeCommand implements Command {
|
|||||||
private readonly path: readonly string[],
|
private readonly path: readonly string[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** The asset made, once the command ran – for the caller to select it. */
|
|
||||||
get assetId(): string {
|
|
||||||
return this.asset?.id ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const owner = requireVariableOwner(project, this.ownerId);
|
const owner = requireVariableOwner(project, this.ownerId);
|
||||||
const def = requireStructVariable(project, this.ownerId, this.key);
|
const def = requireStructVariable(project, this.ownerId, this.key);
|
||||||
@@ -226,11 +233,8 @@ export class MakeStructTypeCommand implements Command {
|
|||||||
throw new CommandError('This struct has a type already');
|
throw new CommandError('This struct has a type already');
|
||||||
if (!this.asset) {
|
if (!this.asset) {
|
||||||
const value = valueAt(owner.props[def.key] ?? def.default, this.path);
|
const value = valueAt(owner.props[def.key] ?? def.default, this.path);
|
||||||
const fields = isStruct(value) ? value : {};
|
const values = isStruct(value) ? value : {};
|
||||||
const variables = at.fields.map((f) => variableOfField(f, fields[f.key]));
|
this.asset = structAssetFrom(project, at.fields, values, this.path.at(-1) ?? this.key);
|
||||||
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
|
|
||||||
const name = nextAssetName(project, this.path.at(-1) ?? this.key);
|
|
||||||
this.asset = { id: newId(), type: 'struct', name, props, variables };
|
|
||||||
}
|
}
|
||||||
project.assets.push(this.asset);
|
project.assets.push(this.asset);
|
||||||
this.rec = new Recorder();
|
this.rec = new Recorder();
|
||||||
@@ -325,10 +329,6 @@ export class MakeListItemTypeCommand implements Command {
|
|||||||
|
|
||||||
constructor(private readonly target: ListTarget) {}
|
constructor(private readonly target: ListTarget) {}
|
||||||
|
|
||||||
get assetId(): string {
|
|
||||||
return this.asset?.id ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const { def, decl } = requireListDecl(project, this.target);
|
const { def, decl } = requireListDecl(project, this.target);
|
||||||
if (decl.itemType && structAssetOf(project, decl.itemType))
|
if (decl.itemType && structAssetOf(project, decl.itemType))
|
||||||
@@ -344,10 +344,7 @@ export class MakeListItemTypeCommand implements Command {
|
|||||||
values[field.key] = item[field.key]!;
|
values[field.key] = item[field.key]!;
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
}
|
}
|
||||||
const variables = fields.map((f) => variableOfField(f, values[f.key]));
|
this.asset = structAssetFrom(project, fields, values, this.target.path.at(-1) ?? this.target.key);
|
||||||
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
|
|
||||||
const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key);
|
|
||||||
this.asset = { id: newId(), type: 'struct', name, props, variables };
|
|
||||||
}
|
}
|
||||||
project.assets.push(this.asset);
|
project.assets.push(this.asset);
|
||||||
this.rec = new Recorder();
|
this.rec = new Recorder();
|
||||||
@@ -370,7 +367,6 @@ function itemFieldDefs(item: StructValue): StructFieldDef[] {
|
|||||||
return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) };
|
return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) };
|
||||||
}
|
}
|
||||||
if (kind === 'struct') return { key, kind, fields: [] };
|
if (kind === 'struct') return { key, kind, fields: [] };
|
||||||
if (kind === 'list') return { key, kind };
|
|
||||||
return { key, kind };
|
return { key, kind };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,11 @@ import {
|
|||||||
type PropertyBag,
|
type PropertyBag,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
type StructValue,
|
type StructValue,
|
||||||
|
withFieldRenamed,
|
||||||
|
withoutField,
|
||||||
} from '../model/Properties';
|
} from '../model/Properties';
|
||||||
import {
|
import {
|
||||||
|
type BehaviorAsset,
|
||||||
isKeptVariable,
|
isKeptVariable,
|
||||||
isPrivateVariable,
|
isPrivateVariable,
|
||||||
setKeptVariable,
|
setKeptVariable,
|
||||||
@@ -180,6 +183,13 @@ export class RenameVariableCommand extends RenameCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The behavior, once it is known to declare `key`. */
|
||||||
|
function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset {
|
||||||
|
const asset = requireBehavior(project, assetId);
|
||||||
|
if (!asset.variables.some((v) => v.key === key)) throw new CommandError(`Variable "${key}" not found`);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a behavior's variable carries on from one `Run behavior` call to the next (#229). */
|
/** Whether a behavior's variable carries on from one `Run behavior` call to the next (#229). */
|
||||||
export class SetVariableKeptCommand implements Command {
|
export class SetVariableKeptCommand implements Command {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -194,9 +204,7 @@ export class SetVariableKeptCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = requireBehavior(project, this.assetId);
|
const asset = requireBehaviorVariable(project, this.assetId, this.key);
|
||||||
if (!asset.variables.some((v) => v.key === this.key))
|
|
||||||
throw new CommandError(`Variable "${this.key}" not found`);
|
|
||||||
this.previous = isKeptVariable(asset, this.key);
|
this.previous = isKeptVariable(asset, this.key);
|
||||||
setKeptVariable(asset, this.key, this.kept);
|
setKeptVariable(asset, this.key, this.kept);
|
||||||
}
|
}
|
||||||
@@ -224,9 +232,7 @@ export class SetVariablePrivateCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = requireBehavior(project, this.assetId);
|
const asset = requireBehaviorVariable(project, this.assetId, this.key);
|
||||||
if (!asset.variables.some((v) => v.key === this.key))
|
|
||||||
throw new CommandError(`Variable "${this.key}" not found`);
|
|
||||||
this.previous = isPrivateVariable(asset, this.key);
|
this.previous = isPrivateVariable(asset, this.key);
|
||||||
setPrivateVariable(asset, this.key, this.hidden);
|
setPrivateVariable(asset, this.key, this.hidden);
|
||||||
this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : [];
|
this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : [];
|
||||||
@@ -362,19 +368,6 @@ function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string)
|
|||||||
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function withoutField(value: StructValue, key: string): StructValue {
|
|
||||||
const next: Record<string, FieldValue> = { ...value };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */
|
|
||||||
function renameField(value: StructValue, from: string, to: string): StructValue {
|
|
||||||
const next: Record<string, FieldValue> = {};
|
|
||||||
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AddStructFieldCommand implements Command {
|
export class AddStructFieldCommand implements Command {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|
||||||
@@ -471,6 +464,6 @@ export class RenameStructFieldCommand extends RenameCommand {
|
|||||||
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||||||
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
||||||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||||
site.write(renameField(site.read(), from, to));
|
site.write(withFieldRenamed(site.read(), from, to));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
isList,
|
isList,
|
||||||
isStruct,
|
isStruct,
|
||||||
|
mapNested,
|
||||||
NO_OBJECT,
|
NO_OBJECT,
|
||||||
type PropertyBag,
|
type PropertyBag,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
@@ -111,16 +112,9 @@ function mapObjects(
|
|||||||
swap: (held: EntityHandle) => unknown,
|
swap: (held: EntityHandle) => unknown,
|
||||||
place?: (at: PropertyValue) => unknown,
|
place?: (at: PropertyValue) => unknown,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (isEntity(value as Value)) return swap(value as EntityHandle);
|
return mapNested(value, (part) => {
|
||||||
if (place && tagOf(value) === 'object') return place(value as PropertyValue);
|
if (isEntity(part as Value)) return swap(part as EntityHandle);
|
||||||
if (isList(value)) {
|
if (place && tagOf(part) === 'object') return place(part as PropertyValue);
|
||||||
const items = value.map((item) => mapObjects(item, swap, place));
|
return undefined;
|
||||||
return items.some((item, i) => item !== value[i]) ? items : value;
|
});
|
||||||
}
|
|
||||||
if (isStruct(value) && tagOf(value) === undefined) {
|
|
||||||
const entries = Object.entries(value);
|
|
||||||
const mapped = entries.map(([key, field]) => [key, mapObjects(field, swap, place)] as const);
|
|
||||||
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ export const BEHAVIOR_END = 'BehaviorEnd';
|
|||||||
export const RUN_BEHAVIOR = 'RunBehavior';
|
export const RUN_BEHAVIOR = 'RunBehavior';
|
||||||
export const VARIABLES = 'Variables';
|
export const VARIABLES = 'Variables';
|
||||||
export const PROPERTIES = 'Properties';
|
export const PROPERTIES = 'Properties';
|
||||||
export const ON_START = 'OnStart';
|
const ON_START = 'OnStart';
|
||||||
|
|
||||||
export const BEHAVIOR_PARAM = 'behavior';
|
export const BEHAVIOR_PARAM = 'behavior';
|
||||||
export const VARIABLES_PARAM = 'variablesAsset';
|
export const VARIABLES_PARAM = 'variablesAsset';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const loopPort = (): PortDef => ({
|
|||||||
* A boolean arriving over an edge is taken literally, which is what wiring a comparison or a
|
* A boolean arriving over an edge is taken literally, which is what wiring a comparison or a
|
||||||
* variable into this input is for.
|
* variable into this input is for.
|
||||||
*/
|
*/
|
||||||
export function loopChoice(value: Value | undefined): boolean | undefined {
|
function loopChoice(value: Value | undefined): boolean | undefined {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
const text = asString(value);
|
const text = asString(value);
|
||||||
if (text === 'true') return true;
|
if (text === 'true') return true;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const contactOutputs = (): PortDef[] => [
|
|||||||
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||||
|
|
||||||
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
||||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `asset` input of `Spawn` and `Is asset`; unconnected, it shows the asset picker. The key is the
|
* The `asset` input of `Spawn` and `Is asset`; unconnected, it shows the asset picker. The key is the
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const OBJECT_BEHAVIOR_PARAM = 'objectBehavior';
|
|||||||
* What a Properties node offers ports from. Textures and scenes are not objects in a running game,
|
* What a Properties node offers ports from. Textures and scenes are not objects in a running game,
|
||||||
* so there is nothing to hand the node for them.
|
* so there is nothing to hand the node for them.
|
||||||
*/
|
*/
|
||||||
export const PROPERTY_TARGETS = [
|
const PROPERTY_TARGETS = [
|
||||||
'any',
|
'any',
|
||||||
'sprite',
|
'sprite',
|
||||||
'text',
|
'text',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
|||||||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||||||
|
|
||||||
export const STATE = 'State';
|
export const STATE = 'State';
|
||||||
export const STATE_MACHINE = 'StateMachine';
|
const STATE_MACHINE = 'StateMachine';
|
||||||
export const STATE_TRANSITION = 'StateTransition';
|
export const STATE_TRANSITION = 'StateTransition';
|
||||||
export const STATE_NAME_PARAM = 'name';
|
export const STATE_NAME_PARAM = 'name';
|
||||||
/** The input naming the machine on `State branch` and `Set state`. */
|
/** The input naming the machine on `State branch` and `Set state`. */
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function findGraphAsset(project: Project, assetId: string): GraphAsset |
|
|||||||
*/
|
*/
|
||||||
export type ObjectHolder = SceneAsset | ContainerAsset;
|
export type ObjectHolder = SceneAsset | ContainerAsset;
|
||||||
|
|
||||||
export function isObjectHolder(asset: Asset): asset is ObjectHolder {
|
function isObjectHolder(asset: Asset): asset is ObjectHolder {
|
||||||
return asset.type === 'scene' || asset.type === 'container';
|
return asset.type === 'scene' || asset.type === 'container';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,38 @@ export function withItemType(value: StructValue, typeId: string): StructValue {
|
|||||||
return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields;
|
return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withoutField(value: StructValue, key: string): StructValue {
|
||||||
|
const { [key]: _dropped, ...rest } = value;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilt rather than reassigned, so the field keeps its place in the value. */
|
||||||
|
export function withFieldRenamed(value: StructValue, from: string, to: string): StructValue {
|
||||||
|
const next: Record<string, FieldValue> = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `value` with each part `swap` answers for replaced by the answer, through lists and untagged
|
||||||
|
* structs to any depth; `undefined` keeps a part and looks inside it. What is untouched keeps its
|
||||||
|
* identity, so a caller can tell whether anything changed.
|
||||||
|
*/
|
||||||
|
export function mapNested(value: unknown, swap: (part: unknown) => unknown): unknown {
|
||||||
|
const swapped = swap(value);
|
||||||
|
if (swapped !== undefined) return swapped;
|
||||||
|
if (isList(value)) {
|
||||||
|
const items = value.map((item) => mapNested(item, swap));
|
||||||
|
return items.some((item, i) => item !== value[i]) ? items : value;
|
||||||
|
}
|
||||||
|
if (isStruct(value) && tagOf(value) === undefined) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
const mapped = entries.map(([key, field]) => [key, mapNested(field, swap)] as const);
|
||||||
|
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/** The value as a run holds it: every item-type mark taken out, at any depth. */
|
/** The value as a run holds it: every item-type mark taken out, at any depth. */
|
||||||
export function withoutItemTypes<T extends PropertyValue | undefined>(value: T): T {
|
export function withoutItemTypes<T extends PropertyValue | undefined>(value: T): T {
|
||||||
if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T;
|
if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { SceneObject } from './SceneObject';
|
|||||||
import { mixColor } from '../util/color';
|
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;
|
const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const;
|
||||||
export type Playback = (typeof PLAYBACKS)[number];
|
export type Playback = (typeof PLAYBACKS)[number];
|
||||||
|
|
||||||
/** What an animation can be built over: something placed, or a whole scene. */
|
/** What an animation can be built over: something placed, or a whole scene. */
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function easeCurve(shape: EasingShape, direction: EasingDirection): Vecto
|
|||||||
return curveFromEase((t) => easedShare(interpolation, t));
|
return curveFromEase((t) => easedShare(interpolation, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function curveFromEase(ease: (t: number) => number): VectorPoint[] {
|
function curveFromEase(ease: (t: number) => number): VectorPoint[] {
|
||||||
let at = [0, 1];
|
let at = [0, 1];
|
||||||
let points = traced(ease, at);
|
let points = traced(ease, at);
|
||||||
for (let round = 0; round < MAX_ROUNDS; round++) {
|
for (let round = 0; round < MAX_ROUNDS; round++) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** An object and every parent above it, nearest first. */
|
/** An object and every parent above it, nearest first. */
|
||||||
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
|
function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
|
||||||
return [...parentChain(object, (o) => parentOf(tree, o))];
|
return [...parentChain(object, (o) => parentOf(tree, o))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function mergeEdits(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
|
function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
|
||||||
return findAssetOf(project, object.assetId, 'container');
|
return findAssetOf(project, object.assetId, 'container');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function angleOf(m: Mat): number {
|
|||||||
return Math.atan2(m.b, m.a);
|
return Math.atan2(m.b, m.a);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMirrored(m: Mat): boolean {
|
function isMirrored(m: Mat): boolean {
|
||||||
return determinant(m) < 0;
|
return determinant(m) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { findAsset, type Project } from './Project';
|
import { findAsset, type Project } from './Project';
|
||||||
import { treeOrder, type ObjectTree } from './hierarchy';
|
import { treeOrder, type ObjectTree } from './hierarchy';
|
||||||
import { resolveObjects } from './instance';
|
import { resolveObjects } from './instance';
|
||||||
import { isList, isStruct, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties';
|
import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties';
|
||||||
import type { SceneObject } from './SceneObject';
|
import type { SceneObject } from './SceneObject';
|
||||||
import { Recorder } from '../util/recorder';
|
import { Recorder } from '../util/recorder';
|
||||||
|
|
||||||
@@ -108,19 +108,10 @@ export function anchorReferences(objects: readonly SceneObject[]): void {
|
|||||||
* keep their identity, so a caller can tell whether anything changed.
|
* keep their identity, so a caller can tell whether anything changed.
|
||||||
*/
|
*/
|
||||||
function withReferences(value: PropertyValue, swap: (ref: string) => string | undefined): PropertyValue {
|
function withReferences(value: PropertyValue, swap: (ref: string) => string | undefined): PropertyValue {
|
||||||
if (tagOf(value) === 'object') {
|
return mapNested(value, (part) => {
|
||||||
const ref = referencedId(value);
|
if (tagOf(part) !== 'object') return undefined;
|
||||||
|
const ref = referencedId(part as PropertyValue);
|
||||||
const next = ref ? swap(ref) : undefined;
|
const next = ref ? swap(ref) : undefined;
|
||||||
return next === undefined || next === ref ? value : objectReference(next);
|
return next === undefined || next === ref ? part : objectReference(next);
|
||||||
}
|
}) as PropertyValue;
|
||||||
if (isList(value)) {
|
|
||||||
const items = value.map((item) => withReferences(item, swap));
|
|
||||||
return items.some((item, i) => item !== value[i]) ? items : value;
|
|
||||||
}
|
|
||||||
if (isStruct(value) && tagOf(value) === undefined) {
|
|
||||||
const entries = Object.entries(value);
|
|
||||||
const mapped = entries.map(([key, field]) => [key, withReferences(field, swap)] as const);
|
|
||||||
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 3, 4, 5, 6];
|
|||||||
|
|
||||||
export const DEFAULT_GAME_SCALE: GameScale = 1;
|
export const DEFAULT_GAME_SCALE: GameScale = 1;
|
||||||
|
|
||||||
export function isGameScale(value: unknown): value is GameScale {
|
function isGameScale(value: unknown): value is GameScale {
|
||||||
return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number);
|
return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** What a type shows until it is switched: all of it, except a name nothing showed before. */
|
/** What a type shows until it is switched: all of it, except a name nothing showed before. */
|
||||||
export function defaultVisibility(type: PlaceableAssetType): TypeVisibility {
|
function defaultVisibility(type: PlaceableAssetType): TypeVisibility {
|
||||||
return { symbol: true, label: hasSceneIcon(type), shape: true };
|
return { symbol: true, label: hasSceneIcon(type), shape: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ import {
|
|||||||
type StructValue,
|
type StructValue,
|
||||||
tagOf,
|
tagOf,
|
||||||
textOf,
|
textOf,
|
||||||
|
withFieldRenamed,
|
||||||
withItemType,
|
withItemType,
|
||||||
} from './Properties';
|
} from './Properties';
|
||||||
import { findAsset, findAssetOf, type Project } from './Project';
|
import { findAsset, findAssetOf, type Project } from './Project';
|
||||||
|
|
||||||
export const PACK_STRUCT = 'PackStruct';
|
const PACK_STRUCT = 'PackStruct';
|
||||||
export const EXPAND_STRUCT = 'ExpandStruct';
|
const EXPAND_STRUCT = 'ExpandStruct';
|
||||||
|
|
||||||
/** Deeper than this only a cycle written by hand can go; it is cut there. */
|
/** Deeper than this only a cycle written by hand can go; it is cut there. */
|
||||||
const MAX_DEPTH = 32;
|
const MAX_DEPTH = 32;
|
||||||
@@ -72,7 +73,7 @@ export function nodeStructType(node: GraphNode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A declared variable as the struct field it becomes in every value of the type. */
|
/** A declared variable as the struct field it becomes in every value of the type. */
|
||||||
export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
|
function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
|
||||||
if (def.kind === 'struct')
|
if (def.kind === 'struct')
|
||||||
return {
|
return {
|
||||||
key: def.key,
|
key: def.key,
|
||||||
@@ -90,7 +91,7 @@ export function typeFields(asset: StructAsset): StructFieldDef[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A new value of the type: every field at the default the asset declares for it. */
|
/** A new value of the type: every field at the default the asset declares for it. */
|
||||||
export function typeDefault(asset: StructAsset): StructValue {
|
function typeDefault(asset: StructAsset): StructValue {
|
||||||
return structOf(typeFields(asset), asset.props);
|
return structOf(typeFields(asset), asset.props);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ function holdersOf(
|
|||||||
* `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what
|
* `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what
|
||||||
* it lacks takes the type's default, and what the type does not declare falls away.
|
* it lacks takes the type's default, and what the type does not declare falls away.
|
||||||
*/
|
*/
|
||||||
export function shapedStruct(
|
function shapedStruct(
|
||||||
project: Project,
|
project: Project,
|
||||||
fields: readonly StructFieldDef[],
|
fields: readonly StructFieldDef[],
|
||||||
value: PropertyValue | undefined,
|
value: PropertyValue | undefined,
|
||||||
@@ -280,7 +281,7 @@ function renamedItems(project: Project, value: PropertyValue, rename: FieldRenam
|
|||||||
const path = [...inside, ...rename.path];
|
const path = [...inside, ...rename.path];
|
||||||
const at = valueAt(next, path);
|
const at = valueAt(next, path);
|
||||||
if (isStruct(at) && rename.from in at)
|
if (isStruct(at) && rename.from in at)
|
||||||
next = withValueAt(next, path, renamedKey(at, rename.from, rename.to)) ?? next;
|
next = withValueAt(next, path, withFieldRenamed(at, rename.from, rename.to)) ?? next;
|
||||||
}
|
}
|
||||||
const out: Record<string, PropertyValue> = {};
|
const out: Record<string, PropertyValue> = {};
|
||||||
for (const [key, item] of Object.entries(next as StructValue))
|
for (const [key, item] of Object.entries(next as StructValue))
|
||||||
@@ -375,12 +376,6 @@ export interface FieldRename {
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renamedKey(value: StructValue, from: string, to: string): StructValue {
|
|
||||||
const next: Record<string, FieldValue> = {};
|
|
||||||
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */
|
/** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */
|
||||||
function applyRename(project: Project, rec: Recorder, rename: FieldRename): void {
|
function applyRename(project: Project, rec: Recorder, rename: FieldRename): void {
|
||||||
const { from, to } = rename;
|
const { from, to } = rename;
|
||||||
@@ -391,7 +386,7 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
|
|||||||
const value = holder.read();
|
const value = holder.read();
|
||||||
const at = valueAt(value, path);
|
const at = valueAt(value, path);
|
||||||
if (!isStruct(at) || !(from in at)) continue;
|
if (!isStruct(at) || !(from in at)) continue;
|
||||||
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
|
holder.write(withValueAt(value, path, withFieldRenamed(at, from, to)) ?? value!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const variable of everyVariable(project))
|
for (const variable of everyVariable(project))
|
||||||
|
|||||||
@@ -175,14 +175,6 @@ export function tilemapLayerNames(project: Project): string[] {
|
|||||||
return [...new Set(names)];
|
return [...new Set(names)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */
|
|
||||||
export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const layer of asset.layers)
|
|
||||||
for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
|
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
|
||||||
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
|
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMate
|
|||||||
return oneWay === true || tileOneWayAngle(tile) === oneWay;
|
return oneWay === true || tileOneWayAngle(tile) === oneWay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
|
||||||
|
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||||
|
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||||
|
return [...ids].flatMap((id) => {
|
||||||
|
const tile = findAssetOf(project, id, 'tile');
|
||||||
|
return tile && isTileSolid(tile) ? [tile] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */
|
/** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */
|
||||||
export function tileMaterials(
|
export function tileMaterials(
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -251,30 +260,17 @@ export function tileMaterials(
|
|||||||
oneWay: OneWayFilter = false,
|
oneWay: OneWayFilter = false,
|
||||||
): TileMaterial[] {
|
): TileMaterial[] {
|
||||||
const materials: TileMaterial[] = [];
|
const materials: TileMaterial[] = [];
|
||||||
const seen = new Set<string>();
|
for (const tile of solidTiles(project, asset)) {
|
||||||
for (const layer of asset.layers)
|
if (!passesFilter(tile, oneWay)) continue;
|
||||||
for (const tileId of cellsTileIds(layer.cells)) {
|
const material = tileMaterial(tile);
|
||||||
if (seen.has(tileId)) continue;
|
if (!materials.some((known) => sameMaterial(known, material))) materials.push(material);
|
||||||
seen.add(tileId);
|
}
|
||||||
const tile = findAssetOf(project, tileId, 'tile');
|
|
||||||
if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue;
|
|
||||||
const material = tileMaterial(tile);
|
|
||||||
if (!materials.some((known) => sameMaterial(known, material))) materials.push(material);
|
|
||||||
}
|
|
||||||
return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness);
|
return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
|
/** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
|
||||||
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
|
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
|
||||||
const angles = new Set<number>();
|
const angles = new Set(solidTiles(project, asset).filter(isTileOneWay).map(tileOneWayAngle));
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const layer of asset.layers)
|
|
||||||
for (const tileId of cellsTileIds(layer.cells)) {
|
|
||||||
if (seen.has(tileId)) continue;
|
|
||||||
seen.add(tileId);
|
|
||||||
const tile = findAssetOf(project, tileId, 'tile');
|
|
||||||
if (tile && isTileSolid(tile) && isTileOneWay(tile)) angles.add(tileOneWayAngle(tile));
|
|
||||||
}
|
|
||||||
return [...angles].sort((a, b) => a - b);
|
return [...angles].sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { isKindWith } from '../properties/kinds';
|
import { isKindWith } from '../properties/kinds';
|
||||||
import { schemaForAsset } from '../properties/PropertySchema';
|
import { schemaForAsset } from '../properties/PropertySchema';
|
||||||
import { referencesAny, trackDef } from './animation';
|
import { referencesAny, trackDef } from './animation';
|
||||||
import type { AnimationAsset, Asset, TileAsset, TilemapAsset } from './Asset';
|
import type { AnimationAsset, Asset, TileAsset } from './Asset';
|
||||||
import { overrideSites } from './instance';
|
import { overrideSites } from './instance';
|
||||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||||
import type { SceneObject } from './SceneObject';
|
import type { SceneObject } from './SceneObject';
|
||||||
@@ -68,15 +68,6 @@ export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tilemaps painted with any of these tiles – intrinsic data, like a tile's pictures. */
|
|
||||||
export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] {
|
|
||||||
const wanted = new Set(ids);
|
|
||||||
return project.assets.filter(
|
|
||||||
(a): a is TilemapAsset =>
|
|
||||||
a.type === 'tilemap' && a.layers.some((l) => Object.values(l.cells).some((id) => wanted.has(id))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
||||||
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
||||||
const wanted = new Set(ids);
|
const wanted = new Set(ids);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ClipType, FillRule, type PathD, type PathsD } from 'clipper2-ts/dist/Co
|
|||||||
import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js';
|
import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js';
|
||||||
import type { VectorRing, VectorShape } from './Asset';
|
import type { VectorRing, VectorShape } from './Asset';
|
||||||
import type { Vec2 } from './Properties';
|
import type { Vec2 } from './Properties';
|
||||||
|
import { cubicAt } from '../util/vec';
|
||||||
import { handleAt, isDrawableRing } from './vectorart';
|
import { handleAt, isDrawableRing } from './vectorart';
|
||||||
import {
|
import {
|
||||||
addCurve,
|
addCurve,
|
||||||
@@ -162,12 +163,3 @@ function curveSteps(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number {
|
|||||||
);
|
);
|
||||||
return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE))));
|
return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
|
|
||||||
const u = 1 - t;
|
|
||||||
const a = u * u * u;
|
|
||||||
const b = 3 * u * u * t;
|
|
||||||
const c = 3 * u * t * t;
|
|
||||||
const d = t * t * t;
|
|
||||||
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { VectorPoint } from './Asset';
|
import type { VectorPoint } from './Asset';
|
||||||
import type { Vec2 } from './Properties';
|
import type { Vec2 } from './Properties';
|
||||||
|
import { cubicAt, splitCubic } from '../util/vec';
|
||||||
|
|
||||||
/** How far a sampled curve may stray from the true one, in canvas pixels. */
|
/** How far a sampled curve may stray from the true one, in canvas pixels. */
|
||||||
export const FLATTEN_TOLERANCE = 0.2;
|
export const FLATTEN_TOLERANCE = 0.2;
|
||||||
@@ -149,10 +150,11 @@ function edgeOn(from: PointOnCurve[], to: PointOnCurve[], table: CurveTable): Cu
|
|||||||
|
|
||||||
/** Whether the piece of `segment` from `t0` to `t1` hugs the straight line between its two ends. */
|
/** Whether the piece of `segment` from `t0` to `t1` hugs the straight line between its two ends. */
|
||||||
function staysOnCurve(segment: CurveSegment, t0: number, t1: number): boolean {
|
function staysOnCurve(segment: CurveSegment, t0: number, t1: number): boolean {
|
||||||
const a = cubicAt(segment, t0);
|
const at = (t: number) => cubicAt(segment.p0, segment.p1, segment.p2, segment.p3, t);
|
||||||
const b = cubicAt(segment, t1);
|
const a = at(t0);
|
||||||
|
const b = at(t1);
|
||||||
for (let step = 1; step < 4; step++) {
|
for (let step = 1; step < 4; step++) {
|
||||||
const point = cubicAt(segment, t0 + (t1 - t0) * (step / 4));
|
const point = at(t0 + (t1 - t0) * (step / 4));
|
||||||
const along = onChord(a, b, point);
|
const along = onChord(a, b, point);
|
||||||
if (!along || along.gap > CHORD_SLACK) return false;
|
if (!along || along.gap > CHORD_SLACK) return false;
|
||||||
}
|
}
|
||||||
@@ -203,31 +205,6 @@ function build(
|
|||||||
function subCurve(segment: CurveSegment, t0: number, t1: number): Vec2[] {
|
function subCurve(segment: CurveSegment, t0: number, t1: number): Vec2[] {
|
||||||
if (t0 > t1) return subCurve(segment, t1, t0).reverse();
|
if (t0 > t1) return subCurve(segment, t1, t0).reverse();
|
||||||
const whole = [segment.p0, segment.p1, segment.p2, segment.p3];
|
const whole = [segment.p0, segment.p1, segment.p2, segment.p3];
|
||||||
const left = t1 >= 1 ? whole : split(whole, t1).left;
|
const left = t1 >= 1 ? whole : splitCubic(whole, t1).left;
|
||||||
return t1 <= 0 ? left : split(left, t0 / t1).right;
|
return t1 <= 0 ? left : splitCubic(left, t0 / t1).right;
|
||||||
}
|
|
||||||
|
|
||||||
/** de Casteljau: the cubic cut in two at `t`, both halves as control points. */
|
|
||||||
function split(p: Vec2[], t: number): { left: Vec2[]; right: Vec2[] } {
|
|
||||||
const a = lerp(p[0]!, p[1]!, t);
|
|
||||||
const b = lerp(p[1]!, p[2]!, t);
|
|
||||||
const c = lerp(p[2]!, p[3]!, t);
|
|
||||||
const d = lerp(a, b, t);
|
|
||||||
const e = lerp(b, c, t);
|
|
||||||
const mid = lerp(d, e, t);
|
|
||||||
return { left: [p[0]!, a, d, mid], right: [mid, e, c, p[3]!] };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 {
|
|
||||||
const u = 1 - t;
|
|
||||||
const a = u * u * u;
|
|
||||||
const b = 3 * u * u * t;
|
|
||||||
const c = 3 * u * t * t;
|
|
||||||
const d = t * t * t;
|
|
||||||
const { p0, p1, p2, p3 } = segment;
|
|
||||||
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ export interface LiveRange {
|
|||||||
to: number;
|
to: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NO_PARTICLES: LiveRange = { from: 0, to: -1 };
|
const NO_PARTICLES: LiveRange = { from: 0, to: -1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When particle `index` was born, in seconds since the source started. A continuous source emits on
|
* When particle `index` was born, in seconds since the source started. A continuous source emits on
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const SORT_WITH_PARENT_KEY = 'sortWithParent';
|
|||||||
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
|
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
|
||||||
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
|
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
|
||||||
*/
|
*/
|
||||||
export const SORT_PROPS: PropertySchema = [
|
const SORT_PROPS: PropertySchema = [
|
||||||
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
|
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ const CONTAINER_SORT_PROPS: PropertySchema = [
|
|||||||
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
|
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
|
||||||
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
|
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
|
||||||
|
|
||||||
export function hasSortProps(type: AssetType): boolean {
|
function hasSortProps(type: AssetType): boolean {
|
||||||
return SORT_TYPES.has(type);
|
return SORT_TYPES.has(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface ClipNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The area's own rectangle in world units. */
|
/** The area's own rectangle in world units. */
|
||||||
export function clipCorners(props: PropertyBag, m: Mat): Vec2[] {
|
function clipCorners(props: PropertyBag, m: Mat): Vec2[] {
|
||||||
return footprintCorners('clip', props, m);
|
return footprintCorners('clip', props, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema';
|
|||||||
|
|
||||||
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
|
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
|
||||||
export const PROPERTY_NAME_PARAM = 'name';
|
export const PROPERTY_NAME_PARAM = 'name';
|
||||||
export const PROPERTY_DEFAULT_PARAM = 'default';
|
const PROPERTY_DEFAULT_PARAM = 'default';
|
||||||
export const PROPERTY_MIN_PARAM = 'min';
|
const PROPERTY_MIN_PARAM = 'min';
|
||||||
export const PROPERTY_MAX_PARAM = 'max';
|
const PROPERTY_MAX_PARAM = 'max';
|
||||||
|
|
||||||
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
|
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
|
||||||
export function propertyNodeParams(step: number): PropertyDef[] {
|
export function propertyNodeParams(step: number): PropertyDef[] {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function propertyName(node: GraphNode): string {
|
|||||||
* rows the sprite brings itself: both rows would share the key and the uniform would read the
|
* rows the sprite brings itself: both rows would share the key and the uniform would read the
|
||||||
* sprite's width instead of the declared default (#63). The compiler names which of the two it is.
|
* sprite's width instead of the declared default (#63). The compiler names which of the two it is.
|
||||||
*/
|
*/
|
||||||
export function isValidPropertyName(name: string): boolean {
|
function isValidPropertyName(name: string): boolean {
|
||||||
return isValidFieldName(name) && !isSpriteProp(name);
|
return isValidFieldName(name) && !isSpriteProp(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface LocalTransform {
|
|||||||
mirrorY: boolean;
|
mirrorY: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
|
const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
|
||||||
|
|
||||||
export function transformOf(props: PropertyBag): LocalTransform {
|
export function transformOf(props: PropertyBag): LocalTransform {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { type AuthStore, roleOf, type SignedIn, WrongCredentialsError } from './AuthStore';
|
import { type AuthStore, roleOf, type SignedIn, WrongCredentialsError } from './AuthStore';
|
||||||
import { apiRequest } from './http';
|
import { apiRequest, jsonBody } from './http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrong password is the expected answer of a login screen, not a breakdown: it gets its own type
|
||||||
|
* so the screen can say one thing about it and another about a server that is down. Changing the
|
||||||
|
* password answers the same way when the current one was wrong; a 422 (too short) carries the
|
||||||
|
* server's wording and stays generic.
|
||||||
|
*/
|
||||||
|
function wrongCredentials(status: number, data: unknown): Error | undefined {
|
||||||
|
return status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class HttpAuthStore implements AuthStore {
|
export class HttpAuthStore implements AuthStore {
|
||||||
constructor(private readonly endpoint: string) {}
|
constructor(private readonly endpoint: string) {}
|
||||||
@@ -18,14 +28,8 @@ export class HttpAuthStore implements AuthStore {
|
|||||||
this.endpoint,
|
this.endpoint,
|
||||||
'POST',
|
'POST',
|
||||||
'auth/login',
|
'auth/login',
|
||||||
{
|
jsonBody({ username, password }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
wrongCredentials,
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
},
|
|
||||||
// A wrong password is the expected answer of a login screen, not a breakdown: it gets its
|
|
||||||
// own type so the screen can say one thing about it and another about a server that is down.
|
|
||||||
(status, data) =>
|
|
||||||
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
|
|
||||||
);
|
);
|
||||||
return { user: body.user, role: roleOf(body.role) };
|
return { user: body.user, role: roleOf(body.role) };
|
||||||
}
|
}
|
||||||
@@ -39,14 +43,8 @@ export class HttpAuthStore implements AuthStore {
|
|||||||
this.endpoint,
|
this.endpoint,
|
||||||
'POST',
|
'POST',
|
||||||
'auth/password',
|
'auth/password',
|
||||||
{
|
jsonBody({ currentPassword, newPassword }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
wrongCredentials,
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
|
||||||
},
|
|
||||||
// Same split as `logIn`: a 401 here means the current password was wrong, which the dialog
|
|
||||||
// says in its own words. A 422 (too short) carries the server's wording and stays generic.
|
|
||||||
(status, data) =>
|
|
||||||
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { apiRequest } from './http';
|
import { apiRequest, jsonBody } from './http';
|
||||||
import {
|
import { type LoadedProject, type ProjectStore, type ProjectSummary, staleRevision } from './ProjectStore';
|
||||||
type LoadedProject,
|
|
||||||
type ProjectStore,
|
|
||||||
type ProjectSummary,
|
|
||||||
StaleRevisionError,
|
|
||||||
} from './ProjectStore';
|
|
||||||
import { deserializeProject, serializeProject } from './serialize';
|
import { deserializeProject, serializeProject } from './serialize';
|
||||||
|
|
||||||
export class HttpProjectStore implements ProjectStore {
|
export class HttpProjectStore implements ProjectStore {
|
||||||
@@ -47,18 +42,6 @@ export class HttpProjectStore implements ProjectStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
|
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
|
||||||
return apiRequest<T>(
|
return apiRequest<T>(this.endpoint, method, route, jsonBody(payload), staleRevision);
|
||||||
this.endpoint,
|
|
||||||
method,
|
|
||||||
route,
|
|
||||||
{
|
|
||||||
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
|
|
||||||
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
|
||||||
},
|
|
||||||
(status, data) =>
|
|
||||||
status === 409
|
|
||||||
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiRequest } from './http';
|
import { apiRequest, jsonBody } from './http';
|
||||||
import { StaleRevisionError } from './ProjectStore';
|
import { staleRevision } from './ProjectStore';
|
||||||
import type { SnapshotStore, SnapshotSummary } from './SnapshotStore';
|
import type { SnapshotStore, SnapshotSummary } from './SnapshotStore';
|
||||||
|
|
||||||
export class HttpSnapshotStore implements SnapshotStore {
|
export class HttpSnapshotStore implements SnapshotStore {
|
||||||
@@ -26,19 +26,7 @@ export class HttpSnapshotStore implements SnapshotStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private request<T>(method: string, path: string, payload?: unknown): Promise<T> {
|
private request<T>(method: string, path: string, payload?: unknown): Promise<T> {
|
||||||
return apiRequest<T>(
|
return apiRequest<T>(this.endpoint, method, path, jsonBody(payload), staleRevision);
|
||||||
this.endpoint,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
{
|
|
||||||
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
|
|
||||||
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
|
||||||
},
|
|
||||||
(status, data) =>
|
|
||||||
status === 409
|
|
||||||
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Role, roleOf } from './AuthStore';
|
import { type Role, roleOf } from './AuthStore';
|
||||||
import { apiRequest } from './http';
|
import { apiRequest, jsonBody } from './http';
|
||||||
import type { Account, AccountChange, UsersStore } from './UsersStore';
|
import type { Account, AccountChange, UsersStore } from './UsersStore';
|
||||||
|
|
||||||
/** A refusal of the API (4xx with an `{error}`) as its bare sentence, without route and status around it. */
|
/** A refusal of the API (4xx with an `{error}`) as its bare sentence, without route and status around it. */
|
||||||
@@ -17,18 +17,14 @@ export class HttpUsersStore implements UsersStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(username: string, password: string, role: Role): Promise<void> {
|
async create(username: string, password: string, role: Role): Promise<void> {
|
||||||
await apiRequest(this.endpoint, 'POST', 'users', this.json({ username, password, role }), refusal);
|
await apiRequest(this.endpoint, 'POST', 'users', jsonBody({ username, password, role }), refusal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(username: string, change: AccountChange): Promise<void> {
|
async update(username: string, change: AccountChange): Promise<void> {
|
||||||
await apiRequest(this.endpoint, 'PATCH', `users/${username}`, this.json(change), refusal);
|
await apiRequest(this.endpoint, 'PATCH', `users/${username}`, jsonBody(change), refusal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(username: string): Promise<void> {
|
async remove(username: string): Promise<void> {
|
||||||
await apiRequest<void>(this.endpoint, 'DELETE', `users/${username}`, {}, refusal);
|
await apiRequest<void>(this.endpoint, 'DELETE', `users/${username}`, {}, refusal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private json(body: object): { headers: Record<string, string>; body: string } {
|
|
||||||
return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,13 @@ export class StaleRevisionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The API's 409, with the revision the server holds, as a `StaleRevisionError` (see `apiRequest`). */
|
||||||
|
export function staleRevision(status: number, data: unknown): Error | undefined {
|
||||||
|
return status === 409
|
||||||
|
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectStore {
|
export interface ProjectStore {
|
||||||
list(): Promise<ProjectSummary[]>;
|
list(): Promise<ProjectSummary[]>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface ReleaseStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The page a released game is played on, next to the editor's own. */
|
/** The page a released game is played on, next to the editor's own. */
|
||||||
export const PLAY_PAGE = '/game-maker/play.html';
|
const PLAY_PAGE = '/game-maker/play.html';
|
||||||
|
|
||||||
export function releaseUrl(origin: string, token: string): string {
|
export function releaseUrl(origin: string, token: string): string {
|
||||||
return `${origin}${PLAY_PAGE}?game=${token}`;
|
return `${origin}${PLAY_PAGE}?game=${token}`;
|
||||||
|
|||||||
@@ -14,10 +14,17 @@ const STATUS_MESSAGES: Record<number, string> = {
|
|||||||
504: 'Server timed out',
|
504: 'Server timed out',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function statusMessage(status: number, statusText: string): string {
|
function statusMessage(status: number, statusText: string): string {
|
||||||
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
|
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The request part of a JSON payload; nothing at all without one. */
|
||||||
|
export function jsonBody(payload?: unknown): Pick<RequestInit, 'headers' | 'body'> {
|
||||||
|
return payload === undefined
|
||||||
|
? {}
|
||||||
|
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One call against the PHP API. Routing goes through the `r` query parameter because the site's
|
* One call against the PHP API. Routing goes through the `r` query parameter because the site's
|
||||||
* nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is
|
* nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is
|
||||||
|
|||||||
@@ -11,3 +11,28 @@ export function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
|||||||
const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0;
|
const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0;
|
||||||
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lerpVec(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||||
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The point at `t` of the cubic Bezier with these control points. */
|
||||||
|
export function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = u * u * u;
|
||||||
|
const b = 3 * u * u * t;
|
||||||
|
const c = 3 * u * t * t;
|
||||||
|
const d = t * t * t;
|
||||||
|
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** de Casteljau: the cubic cut in two at `t`, both halves as control points. */
|
||||||
|
export function splitCubic(p: readonly Vec2[], t: number): { left: Vec2[]; right: Vec2[] } {
|
||||||
|
const a = lerpVec(p[0]!, p[1]!, t);
|
||||||
|
const b = lerpVec(p[1]!, p[2]!, t);
|
||||||
|
const c = lerpVec(p[2]!, p[3]!, t);
|
||||||
|
const d = lerpVec(a, b, t);
|
||||||
|
const e = lerpVec(b, c, t);
|
||||||
|
const mid = lerpVec(d, e, t);
|
||||||
|
return { left: [p[0]!, a, d, mid], right: [mid, e, c, p[3]!] };
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import { dialogButtons } from './FormDialog';
|
|||||||
|
|
||||||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||||
export const PICKER_WIDTH = 560;
|
export const PICKER_WIDTH = 560;
|
||||||
export const PICKER_HEIGHT = 340;
|
const PICKER_HEIGHT = 340;
|
||||||
|
|
||||||
/** One offered file, with the asset that already wears it (if any). */
|
/** One offered file, with the asset that already wears it (if any). */
|
||||||
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
||||||
@@ -46,7 +46,7 @@ export function withAssetNames<S extends StoredFile>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The line under the list for a picked file: its upload name and size, and who wears it. */
|
/** The line under the list for a picked file: its upload name and size, and who wears it. */
|
||||||
export function describeStored(item: PickerItem): string {
|
function describeStored(item: PickerItem): string {
|
||||||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||||||
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Dropdown, Menu } from '../ui';
|
||||||
|
import { POINT_KINDS, type PointKind } from './polygonEdit';
|
||||||
|
|
||||||
|
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
||||||
|
export const POINT_BUTTON_WIDTH = 150;
|
||||||
|
|
||||||
|
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||||
|
straight: 'Straight',
|
||||||
|
corner: 'Corner',
|
||||||
|
smooth: 'Smooth',
|
||||||
|
symmetric: 'Symmetric',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What the button edits: the point last picked on a canvas, null while none is. */
|
||||||
|
export interface PointKindTarget {
|
||||||
|
readonly pickedKind: PointKind | null;
|
||||||
|
setPickedKind(kind: PointKind): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header's point type (#195), in the Vector art and the Curve tab: the four types a point can
|
||||||
|
* be, the one it is now ticked.
|
||||||
|
*/
|
||||||
|
export class PointKindButton extends Dropdown {
|
||||||
|
constructor(private readonly target: () => PointKindTarget) {
|
||||||
|
super(pointLabel(null), { width: POINT_BUTTON_WIDTH });
|
||||||
|
this.onClick = () => this.openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the picked point: its type, or disabled while none is picked. */
|
||||||
|
sync(): void {
|
||||||
|
const kind = this.target().pickedKind;
|
||||||
|
this.setText(pointLabel(kind));
|
||||||
|
this.setEnabled(kind !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openMenu(): void {
|
||||||
|
const items = POINT_KINDS.map((kind) => ({
|
||||||
|
label: POINT_KIND_LABELS[kind],
|
||||||
|
checked: () => this.target().pickedKind === kind,
|
||||||
|
onSelect: () => this.target().setPickedKind(kind),
|
||||||
|
}));
|
||||||
|
new Menu(items).openBelow(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointLabel(kind: PointKind | null): string {
|
||||||
|
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const RADIUS = 6;
|
|||||||
* Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a
|
* Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a
|
||||||
* border of their own, so the row keeps its own value instead of `Theme.padding`.
|
* border of their own, so the row keeps its own value instead of `Theme.padding`.
|
||||||
*/
|
*/
|
||||||
export const TAB_PADDING = 6;
|
const TAB_PADDING = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One card of the tab row. It is drawn as a real index card rather than as a toggled button: a
|
* One card of the tab row. It is drawn as a real index card rather than as a toggled button: a
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const RULER_HEIGHT = 22;
|
|||||||
export const ROW_HEIGHT = 26;
|
export const ROW_HEIGHT = 26;
|
||||||
export const MIN_CELL_WIDTH = 4;
|
export const MIN_CELL_WIDTH = 4;
|
||||||
export const MAX_CELL_WIDTH = 48;
|
export const MAX_CELL_WIDTH = 48;
|
||||||
export const DEFAULT_CELL_WIDTH = 16;
|
const DEFAULT_CELL_WIDTH = 16;
|
||||||
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
|
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
|
||||||
export const KEY_SIZE = 10;
|
export const KEY_SIZE = 10;
|
||||||
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
|
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function assetNameForFile(fileName: string, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||||
export function pickFiles(accept: string): Promise<File[]> {
|
function pickFiles(accept: string): Promise<File[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function pickedObject(state: EditorState, holderId: string): string {
|
|||||||
* is an original or an earlier paste; otherwise under the parent it had, if this holder has it,
|
* is an original or an earlier paste; otherwise under the parent it had, if this holder has it,
|
||||||
* else on top.
|
* else on top.
|
||||||
*/
|
*/
|
||||||
export function pasteParents(
|
function pasteParents(
|
||||||
project: Project,
|
project: Project,
|
||||||
holder: ObjectHolder,
|
holder: ObjectHolder,
|
||||||
clip: ObjectClip,
|
clip: ObjectClip,
|
||||||
@@ -156,7 +156,7 @@ export function pasteObjects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
|
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
|
||||||
export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
|
function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
|
||||||
return paletteEntries(project, asset).some(
|
return paletteEntries(project, asset).some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.def.type === node.type &&
|
entry.def.type === node.type &&
|
||||||
@@ -165,7 +165,7 @@ export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
|
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
|
||||||
export function fittingPart(
|
function fittingPart(
|
||||||
project: Project,
|
project: Project,
|
||||||
asset: GraphAsset,
|
asset: GraphAsset,
|
||||||
clip: GraphClip,
|
clip: GraphClip,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset
|
|||||||
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
|
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { handleAt } from '../../core/model/vectorart';
|
|
||||||
import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui';
|
import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui';
|
||||||
|
import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips';
|
||||||
import {
|
import {
|
||||||
type PointKind,
|
type PointKind,
|
||||||
pointKind,
|
pointKind,
|
||||||
@@ -45,10 +45,6 @@ import {
|
|||||||
withCurvePointSet,
|
withCurvePointSet,
|
||||||
} from './curveView';
|
} from './curveView';
|
||||||
|
|
||||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
|
||||||
const POINT_SIZE = 8;
|
|
||||||
const HANDLE_RADIUS = 4;
|
|
||||||
const DRAG_THRESHOLD = 3;
|
|
||||||
/** Steps the drawn line is walked in; a bounce needs this many to keep its corners. */
|
/** Steps the drawn line is walked in; a bounce needs this many to keep its corners. */
|
||||||
const LINE_STEPS = 240;
|
const LINE_STEPS = 240;
|
||||||
/** Where the preview's second dot runs, in units of `t` right of the graph. */
|
/** Where the preview's second dot runs, in units of `t` right of the graph. */
|
||||||
@@ -468,28 +464,8 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
|||||||
|
|
||||||
/** Every point with its handles, in widget pixels so they keep their size. */
|
/** Every point with its handles, in widget pixels so they keep their size. */
|
||||||
private drawPoints(points: readonly VectorPoint[]): void {
|
private drawPoints(points: readonly VectorPoint[]): void {
|
||||||
const half = POINT_SIZE / 2;
|
for (const [index, point] of points.entries())
|
||||||
for (const [index, point] of points.entries()) {
|
drawPointGrip(this.overlay, point, index === this.picked, (t, v) => this.local(t, v));
|
||||||
const at = this.local(point.x, point.y);
|
|
||||||
for (const side of ['in', 'out'] as const) {
|
|
||||||
const handle = handleAt(point, side);
|
|
||||||
if (!handle) continue;
|
|
||||||
const tip = this.local(handle.x, handle.y);
|
|
||||||
this.overlay
|
|
||||||
.moveTo(at.x, at.y)
|
|
||||||
.lineTo(tip.x, tip.y)
|
|
||||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
|
||||||
this.overlay
|
|
||||||
.circle(tip.x, tip.y, HANDLE_RADIUS)
|
|
||||||
.fill(Theme.colors.viewport)
|
|
||||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
|
||||||
}
|
|
||||||
const isPicked = index === this.picked;
|
|
||||||
this.overlay
|
|
||||||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
|
||||||
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
|
||||||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The playhead's knob: under the graph and its `t` numbers, below whatever the curve reaches. */
|
/** The playhead's knob: under the graph and its `t` numbers, below whatever the curve reaches. */
|
||||||
|
|||||||
@@ -3,26 +3,13 @@ import { curvePresets } from '../../core/model/curvePresets';
|
|||||||
import { Button, Dropdown, Label, Menu, type MenuEntry, NumberField } from '../../ui';
|
import { Button, Dropdown, Label, Menu, type MenuEntry, NumberField } from '../../ui';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { SnapToggle } from '../graph/SnapToggle';
|
import { SnapToggle } from '../graph/SnapToggle';
|
||||||
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
import { PointKindButton } from '../PointKindButton';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { CurveCanvas } from './CurveCanvas';
|
import { CurveCanvas } from './CurveCanvas';
|
||||||
|
|
||||||
const INFO_WIDTH = 90;
|
const INFO_WIDTH = 90;
|
||||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
|
||||||
const POINT_WIDTH = 150;
|
|
||||||
const PLACE_WIDTH = 64;
|
const PLACE_WIDTH = 64;
|
||||||
|
|
||||||
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
|
||||||
straight: 'Straight',
|
|
||||||
corner: 'Corner',
|
|
||||||
smooth: 'Smooth',
|
|
||||||
symmetric: 'Symmetric',
|
|
||||||
};
|
|
||||||
|
|
||||||
function pointLabel(kind: PointKind | null): string {
|
|
||||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Curve tab (#247): the canvas edits the curve, the header starts it over from a built-in ease,
|
* The Curve tab (#247): the canvas edits the curve, the header starts it over from a built-in ease,
|
||||||
* sets the picked point's type as in the Vector art tab and its place by number, snaps, and runs the
|
* sets the picked point's type as in the Vector art tab and its place by number, snaps, and runs the
|
||||||
@@ -31,7 +18,7 @@ function pointLabel(kind: PointKind | null): string {
|
|||||||
export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly presetButton = new Dropdown('Preset');
|
readonly presetButton = new Dropdown('Preset');
|
||||||
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
readonly pointButton = new PointKindButton(() => this.canvas);
|
||||||
readonly tField: NumberField;
|
readonly tField: NumberField;
|
||||||
readonly vField: NumberField;
|
readonly vField: NumberField;
|
||||||
readonly snap = new SnapToggle(() => this.canvas);
|
readonly snap = new SnapToggle(() => this.canvas);
|
||||||
@@ -40,7 +27,6 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
|||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
this.presetButton.onClick = () => this.openPresetMenu();
|
this.presetButton.onClick = () => this.openPresetMenu();
|
||||||
this.pointButton.onClick = () => this.openPointMenu();
|
|
||||||
this.tField = this.placeField((t, commit) => this.movePicked(t, null, commit));
|
this.tField = this.placeField((t, commit) => this.movePicked(t, null, commit));
|
||||||
this.vField = this.placeField((v, commit) => this.movePicked(null, v, commit));
|
this.vField = this.placeField((v, commit) => this.movePicked(null, v, commit));
|
||||||
this.previewButton = new Button('Preview', {
|
this.previewButton = new Button('Preview', {
|
||||||
@@ -109,19 +95,8 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
|||||||
new Menu(entries, { searchable: true }).openBelow(this.presetButton);
|
new Menu(entries, { searchable: true }).openBelow(this.presetButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openPointMenu(): void {
|
|
||||||
const items = POINT_KINDS.map((kind) => ({
|
|
||||||
label: POINT_KIND_LABELS[kind],
|
|
||||||
checked: () => this.canvas.pickedKind === kind,
|
|
||||||
onSelect: () => this.canvas.setPickedKind(kind),
|
|
||||||
}));
|
|
||||||
new Menu(items).openBelow(this.pointButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncPoint(): void {
|
private syncPoint(): void {
|
||||||
const kind = this.canvas.pickedKind;
|
this.pointButton.sync();
|
||||||
this.pointButton.setText(pointLabel(kind));
|
|
||||||
this.pointButton.setEnabled(kind !== null);
|
|
||||||
const place = this.canvas.pickedPlace;
|
const place = this.canvas.pickedPlace;
|
||||||
this.tField.setNumber(place?.x ?? 0);
|
this.tField.setNumber(place?.x ?? 0);
|
||||||
this.vField.setNumber(place?.y ?? 0);
|
this.vField.setNumber(place?.y ?? 0);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const SEEK_STEPS = 1000;
|
|||||||
const FIT_MARGIN = 0.12;
|
const FIT_MARGIN = 0.12;
|
||||||
|
|
||||||
/** One pass of the preview: the dot runs `PREVIEW_RUN` seconds, then rests at the end. */
|
/** One pass of the preview: the dot runs `PREVIEW_RUN` seconds, then rests at the end. */
|
||||||
export const PREVIEW_RUN = 1;
|
const PREVIEW_RUN = 1;
|
||||||
const PREVIEW_REST = 0.5;
|
const PREVIEW_REST = 0.5;
|
||||||
|
|
||||||
export function curveToImage(points: readonly VectorPoint[]): VectorRing {
|
export function curveToImage(points: readonly VectorPoint[]): VectorRing {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class FontList extends RowList<FontItem, FontRow> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FontPicker extends FilePicker<FontItem> {
|
class FontPicker extends FilePicker<FontItem> {
|
||||||
declare protected readonly list: FontList;
|
declare protected readonly list: FontList;
|
||||||
|
|
||||||
constructor(private readonly deps: FontImportDeps) {
|
constructor(private readonly deps: FontImportDeps) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
|||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||||
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
||||||
|
|
||||||
const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i;
|
const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface Rect {
|
|||||||
|
|
||||||
/** Length of an arrowhead along the line, and half its width across. */
|
/** Length of an arrowhead along the line, and half its width across. */
|
||||||
export const ARROW = 10;
|
export const ARROW = 10;
|
||||||
export const ARROW_HALF_WIDTH = 4.5;
|
const ARROW_HALF_WIDTH = 4.5;
|
||||||
/** Air between a node's border and the end of the arrow touching it. */
|
/** Air between a node's border and the end of the arrow touching it. */
|
||||||
export const BORDER_GAP = 7;
|
export const BORDER_GAP = 7;
|
||||||
/** How far apart two transitions between the same pair of states are pushed. */
|
/** How far apart two transitions between the same pair of states are pushed. */
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export interface Field {
|
|||||||
* The row alone, or – for a field with something below it – the row with that under it, `depth`
|
* The row alone, or – for a field with something below it – the row with that under it, `depth`
|
||||||
* indent steps in.
|
* indent steps in.
|
||||||
*/
|
*/
|
||||||
export function withBelow(row: Widget, field: Field, depth: number): Widget {
|
function withBelow(row: Widget, field: Field, depth: number): Widget {
|
||||||
if (!field.below) return row;
|
if (!field.below) return row;
|
||||||
const stack = new VStack({ gap: Theme.gap });
|
const stack = new VStack({ gap: Theme.gap });
|
||||||
stack.add(row);
|
stack.add(row);
|
||||||
@@ -362,7 +362,7 @@ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */
|
/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */
|
||||||
export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
|
function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
|
||||||
const per = handlers.structFieldHandlers;
|
const per = handlers.structFieldHandlers;
|
||||||
return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY;
|
return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
type ObjectRef,
|
type ObjectRef,
|
||||||
resolvedHolder,
|
resolvedHolder,
|
||||||
} from '../../core/model/instance';
|
} from '../../core/model/instance';
|
||||||
|
import type { Asset } from '../../core/model/Asset';
|
||||||
import type { Axis, PropertyBag } from '../../core/model/Properties';
|
import type { Axis, PropertyBag } from '../../core/model/Properties';
|
||||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
||||||
|
import type { SceneObject } from '../../core/model/SceneObject';
|
||||||
import {
|
import {
|
||||||
deviatesFromAsset,
|
deviatesFromAsset,
|
||||||
deviatesFromTemplate,
|
deviatesFromTemplate,
|
||||||
@@ -28,6 +30,18 @@ export function viewOf(project: Project, holderId: string): ObjectHolder | undef
|
|||||||
return holder && resolvedHolder(project, holder);
|
return holder && resolvedHolder(project, holder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The object as the inspector addresses it, with its holder and the asset it was placed from. */
|
||||||
|
export function shownObject(
|
||||||
|
project: Project,
|
||||||
|
holderId: string,
|
||||||
|
objectId: string,
|
||||||
|
): { view: ObjectHolder; object: SceneObject; asset: Asset } | null {
|
||||||
|
const view = viewOf(project, holderId);
|
||||||
|
const object = view && findObject(view, objectId);
|
||||||
|
const asset = object && findAsset(project, object.assetId);
|
||||||
|
return view && object && asset ? { view, object, asset } : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
|
export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
|
||||||
const holder = findObjectHolder(project, holderId);
|
const holder = findObjectHolder(project, holderId);
|
||||||
return holder && instanceTargetOf(project, holder, objectId);
|
return holder && instanceTargetOf(project, holder, objectId);
|
||||||
@@ -56,10 +70,9 @@ export function deviations(
|
|||||||
objectId: string,
|
objectId: string,
|
||||||
key: string,
|
key: string,
|
||||||
): { template: boolean; asset: boolean; applyToAsset: boolean } {
|
): { template: boolean; asset: boolean; applyToAsset: boolean } {
|
||||||
const view = viewOf(state.project, holderId);
|
const shown = shownObject(state.project, holderId, objectId);
|
||||||
const object = view && findObject(view, objectId);
|
if (!shown) return { template: false, asset: false, applyToAsset: false };
|
||||||
const asset = object && findAsset(state.project, object.assetId);
|
const { object, asset } = shown;
|
||||||
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
|
|
||||||
const target = targetOf(state.project, holderId, objectId);
|
const target = targetOf(state.project, holderId, objectId);
|
||||||
const fromAsset = deviatesFromAsset(state.project, object, asset, key);
|
const fromAsset = deviatesFromAsset(state.project, object, asset, key);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -63,7 +63,15 @@ import { parentRow } from './parentRow';
|
|||||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||||
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
||||||
import { buildVariable } from './variableRows';
|
import { buildVariable } from './variableRows';
|
||||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
import {
|
||||||
|
currentProps,
|
||||||
|
currentRef,
|
||||||
|
deviations,
|
||||||
|
positionMoved,
|
||||||
|
shownObject,
|
||||||
|
targetOf,
|
||||||
|
viewOf,
|
||||||
|
} from './objectQueries';
|
||||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,10 +148,9 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
|
|||||||
|
|
||||||
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
|
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
|
||||||
const { project } = host.state;
|
const { project } = host.state;
|
||||||
const view = viewOf(project, holderId);
|
const shown = shownObject(project, holderId, objectId);
|
||||||
const object = view && findObject(view, objectId);
|
if (!shown) return;
|
||||||
const asset = object && findAsset(project, object.assetId);
|
const { view, object, asset } = shown;
|
||||||
if (!view || !object || !asset) return;
|
|
||||||
const target = targetOf(project, holderId, objectId);
|
const target = targetOf(project, holderId, objectId);
|
||||||
|
|
||||||
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
|
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
|
||||||
@@ -230,10 +237,9 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
|||||||
*/
|
*/
|
||||||
export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void {
|
export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void {
|
||||||
const { project } = host.state;
|
const { project } = host.state;
|
||||||
const view = viewOf(project, holderId);
|
const shown = shownObject(project, holderId, objectId);
|
||||||
const object = view && findObject(view, objectId);
|
if (!shown) return;
|
||||||
const asset = object && findAsset(project, object.assetId);
|
const { object, asset } = shown;
|
||||||
if (!object || !asset) return;
|
|
||||||
const values = resolveSceneObjectProps(project, asset, object);
|
const values = resolveSceneObjectProps(project, asset, object);
|
||||||
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
||||||
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
||||||
@@ -339,10 +345,9 @@ function contentSection(
|
|||||||
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
|
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
|
||||||
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
|
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
|
||||||
const { project } = host.state;
|
const { project } = host.state;
|
||||||
const view = viewOf(project, holderId);
|
const shown = shownObject(project, holderId, objectId);
|
||||||
const object = view && findObject(view, objectId);
|
if (!shown) return false;
|
||||||
const asset = object && findAsset(project, object.assetId);
|
const { object, asset } = shown;
|
||||||
if (!view || !object || !asset) return false;
|
|
||||||
host.nameField?.setValue(object.name);
|
host.nameField?.setValue(object.name);
|
||||||
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
|
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
|
||||||
host.fields.get(ASSET_ROW)?.setValue(asset.name);
|
host.fields.get(ASSET_ROW)?.setValue(asset.name);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
type Widget,
|
type Widget,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import {
|
import {
|
||||||
type FieldValue,
|
|
||||||
isList,
|
isList,
|
||||||
isStruct,
|
isStruct,
|
||||||
itemTypeOf,
|
itemTypeOf,
|
||||||
@@ -34,7 +33,9 @@ import {
|
|||||||
type StructValue,
|
type StructValue,
|
||||||
textOf,
|
textOf,
|
||||||
untag,
|
untag,
|
||||||
|
withFieldRenamed,
|
||||||
withItemType,
|
withItemType,
|
||||||
|
withoutField,
|
||||||
} from '../../core/model/Properties';
|
} from '../../core/model/Properties';
|
||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
||||||
@@ -463,7 +464,7 @@ export class InlineList extends VStack {
|
|||||||
const name = locked
|
const name = locked
|
||||||
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
|
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
|
||||||
: nameField(field.key, depth, (to) => {
|
: nameField(field.key, depth, (to) => {
|
||||||
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
|
if (to && to !== field.key) this.replaceAt(access, path, withFieldRenamed(now(), field.key, to));
|
||||||
});
|
});
|
||||||
line.add(fieldMark);
|
line.add(fieldMark);
|
||||||
line.add(name);
|
line.add(name);
|
||||||
@@ -607,18 +608,6 @@ function addFieldMenu(add: (kind: NestedKind) => void): Dropdown {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withoutField = (bundle: PropertyValue, key: string): ListItem => {
|
|
||||||
const next = { ...structItemOf(bundle) };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem => {
|
|
||||||
const next: Record<string, FieldValue> = {};
|
|
||||||
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
|
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
|
||||||
* are built from.
|
* are built from.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function groupMenuItems(
|
|||||||
* Asks for the name of a new group – or, from a dropdown, for an existing group to put the objects
|
* Asks for the name of a new group – or, from a dropdown, for an existing group to put the objects
|
||||||
* into. A group all of them are in already is not offered.
|
* into. A group all of them are in already is not offered.
|
||||||
*/
|
*/
|
||||||
export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
|
function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
|
||||||
const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object';
|
const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object';
|
||||||
const groups = (source.groups ?? []).filter((g) => {
|
const groups = (source.groups ?? []).filter((g) => {
|
||||||
const members = groupMemberIds(source, g.id);
|
const members = groupMemberIds(source, g.id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { dialogSlot, FormDialog } from '../FormDialog';
|
|||||||
const FIELD_WIDTH = 220;
|
const FIELD_WIDTH = 220;
|
||||||
|
|
||||||
/** The dropdown entry that makes a group instead of joining one. */
|
/** The dropdown entry that makes a group instead of joining one. */
|
||||||
export const NEW_GROUP = 'New group';
|
const NEW_GROUP = 'New group';
|
||||||
|
|
||||||
const slot = dialogSlot<GroupNameDialog>();
|
const slot = dialogSlot<GroupNameDialog>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The small symbols in inspector rows and lists (#214): kind marks, row actions, transition arrows.
|
* The small symbols in inspector rows and lists (#214): kind marks, row actions, transition arrows.
|
||||||
* They used to be characters, which the bundled Latin font does not have.
|
* Drawn, not typed: the bundled Latin font has none of these characters.
|
||||||
*/
|
*/
|
||||||
import type { Kind } from '../../core/properties/kinds';
|
import type { Kind } from '../../core/properties/kinds';
|
||||||
import apply from './icons/apply.svg?raw';
|
import apply from './icons/apply.svg?raw';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left,
|
* The top bar's icons (#183). It carries no captions: menu, undo and redo on the left, play and
|
||||||
* play and pause in the middle, so each button is its picture and nothing else.
|
* pause in the middle, so each button is its picture and nothing else.
|
||||||
*/
|
*/
|
||||||
import type { VisibilityPart } from '../../core/model/sceneVisibility';
|
import type { VisibilityPart } from '../../core/model/sceneVisibility';
|
||||||
import eye from './icons/eye.svg?raw';
|
import eye from './icons/eye.svg?raw';
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ class PluginRow extends HStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plugins (#114, #119), in the user management tab since #179: every file in `data/plugins/` with a switch. Switching one on loads
|
* The plugins (#114, #119) in the user management tab (#179): every file in `data/plugins/` with a
|
||||||
* and activates it there and then, switching it off takes its tabs and listeners away again – the
|
* switch. Switching one on loads and activates it there and then, switching it off takes its tabs
|
||||||
* switch is part of the open project, so it travels with it and undo takes it back.
|
* and listeners away again – the switch is part of the open project, so it travels with it and undo
|
||||||
|
* takes it back.
|
||||||
*
|
*
|
||||||
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
|
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
|
||||||
* reading a name out of it would mean running it.
|
* reading a name out of it would mean running it.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Graphics } from 'pixi.js';
|
||||||
|
import type { VectorPoint } from '../core/model/Asset';
|
||||||
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
|
import { handleAt } from '../core/model/vectorart';
|
||||||
|
import { Theme } from '../ui';
|
||||||
|
|
||||||
|
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||||||
|
export const POINT_SIZE = 8;
|
||||||
|
/** Radius of a Bezier handle's knob, in screen pixels. */
|
||||||
|
export const HANDLE_RADIUS = 4;
|
||||||
|
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||||
|
export const DRAG_THRESHOLD = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One point of a Vector art ring or a curve with its handles, in widget pixels so they keep their
|
||||||
|
* size; `toLocal` says where a place in the picture is drawn.
|
||||||
|
*/
|
||||||
|
export function drawPointGrip(
|
||||||
|
g: Graphics,
|
||||||
|
point: VectorPoint,
|
||||||
|
picked: boolean,
|
||||||
|
toLocal: (x: number, y: number) => Vec2,
|
||||||
|
): void {
|
||||||
|
const at = toLocal(point.x, point.y);
|
||||||
|
for (const side of ['in', 'out'] as const) {
|
||||||
|
const handle = handleAt(point, side);
|
||||||
|
if (!handle) continue;
|
||||||
|
const tip = toLocal(handle.x, handle.y);
|
||||||
|
g.moveTo(at.x, at.y).lineTo(tip.x, tip.y).stroke({ width: 1, color: Theme.colors.marquee });
|
||||||
|
g.circle(tip.x, tip.y, HANDLE_RADIUS)
|
||||||
|
.fill(Theme.colors.viewport)
|
||||||
|
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||||
|
}
|
||||||
|
const half = POINT_SIZE / 2;
|
||||||
|
g.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||||||
|
.fill(picked ? Theme.colors.accent : Theme.colors.accentText)
|
||||||
|
.stroke({ width: 1, color: picked ? Theme.colors.accentText : Theme.colors.accent });
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { VectorPoint, VectorPointMode, VectorRing } from '../core/model/Asset';
|
import type { VectorPoint, VectorPointMode, VectorRing } from '../core/model/Asset';
|
||||||
import type { Vec2 } from '../core/model/Properties';
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
import { closestOnSegment } from '../core/util/vec';
|
import { closestOnSegment, cubicAt, splitCubic } from '../core/util/vec';
|
||||||
// Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once.
|
// Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once.
|
||||||
import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart';
|
import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart';
|
||||||
|
|
||||||
@@ -162,11 +162,7 @@ export function nearestOnSegment(
|
|||||||
export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 {
|
export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 {
|
||||||
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
||||||
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
||||||
const u = 1 - t;
|
return cubicAt(from, c1, c2, to, t);
|
||||||
return {
|
|
||||||
x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
|
|
||||||
y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */
|
/** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */
|
||||||
@@ -318,20 +314,16 @@ export function withPointOnSegment(ring: VectorRing, index: number, t: number):
|
|||||||
if (from.out || to.in) {
|
if (from.out || to.in) {
|
||||||
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
||||||
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
||||||
const a = lerp(from, c1, t);
|
const { left, right } = splitCubic([from, c1, c2, to], t);
|
||||||
const b = lerp(c1, c2, t);
|
|
||||||
const c = lerp(c2, to, t);
|
|
||||||
const d = lerp(a, b, t);
|
|
||||||
const e = lerp(b, c, t);
|
|
||||||
const next = points[(index + 1) % ring.points.length]!;
|
const next = points[(index + 1) % ring.points.length]!;
|
||||||
setHandle(points[index]!, 'out', a);
|
setHandle(points[index]!, 'out', left[1]!);
|
||||||
setHandle(next, 'in', c);
|
setHandle(next, 'in', right[2]!);
|
||||||
// The split keeps the directions but shortens one handle of each end: no longer symmetric,
|
// The split keeps the directions but shortens one handle of each end: no longer symmetric,
|
||||||
// still smooth. The new point sits on a curve, so its handles are in line – smooth as well.
|
// still smooth. The new point sits on a curve, so its handles are in line – smooth as well.
|
||||||
for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth';
|
for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth';
|
||||||
inserted = { x: at.x, y: at.y, mode: 'smooth' };
|
inserted = { x: at.x, y: at.y, mode: 'smooth' };
|
||||||
setHandle(inserted, 'in', d);
|
setHandle(inserted, 'in', left[2]!);
|
||||||
setHandle(inserted, 'out', e);
|
setHandle(inserted, 'out', right[1]!);
|
||||||
if (!inserted.in && !inserted.out) delete inserted.mode;
|
if (!inserted.in && !inserted.out) delete inserted.mode;
|
||||||
}
|
}
|
||||||
points.splice(index + 1, 0, inserted);
|
points.splice(index + 1, 0, inserted);
|
||||||
@@ -372,7 +364,3 @@ function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {
|
|||||||
if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side];
|
if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side];
|
||||||
else point[side] = offset;
|
else point[side] = offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,7 @@ class SoundList extends RowList<SoundItem, SoundRow> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SoundPicker extends FilePicker<SoundItem> {
|
class SoundPicker extends FilePicker<SoundItem> {
|
||||||
declare protected readonly list: SoundList;
|
declare protected readonly list: SoundList;
|
||||||
|
|
||||||
constructor(private readonly deps: SoundImportDeps) {
|
constructor(private readonly deps: SoundImportDeps) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
|||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||||
export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
|
const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
|
||||||
|
|
||||||
const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i;
|
const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i;
|
||||||
|
|
||||||
|
|||||||
@@ -308,11 +308,6 @@ export class EditorState {
|
|||||||
return findScene(this.project, this.project.activeSceneId);
|
return findScene(this.project, this.project.activeSceneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */
|
|
||||||
get runtimeProject(): Project | null {
|
|
||||||
return this.mode === 'play' ? this.project : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which scene Play builds its world from. Two answers rather than one, because the project has
|
* Which scene Play builds its world from. Two answers rather than one, because the project has
|
||||||
* two: where the game begins, and what you are working on this minute. The switch says which
|
* two: where the game begins, and what you are working on this minute. The switch says which
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class ImageGrid extends PickerList<ImageItem, Tile> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImagePicker extends FilePicker<ImageItem> {
|
class ImagePicker extends FilePicker<ImageItem> {
|
||||||
declare protected readonly list: ImageGrid;
|
declare protected readonly list: ImageGrid;
|
||||||
|
|
||||||
constructor(private readonly deps: TextureImportDeps) {
|
constructor(private readonly deps: TextureImportDeps) {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
super();
|
super();
|
||||||
this.perFrame = new FramePreview(state, 'texture-drag');
|
this.perFrame = new FramePreview(state, 'texture-drag');
|
||||||
this.marks.addChild(this.sheet, this.overlay);
|
this.marks.addChild(this.sheet, this.overlay);
|
||||||
// Plates under the outlines, as before the split: a corner handle must not sink under a name.
|
// Plates under the outlines: a corner handle must not sink under a name.
|
||||||
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
|
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
|
||||||
|
|
||||||
state.on('project', () => this.redraw());
|
state.on('project', () => this.redraw());
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function fitView(imageWidth: number, imageHeight: number, w: number, h: n
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampZoom(zoom: number): number {
|
function clampZoom(zoom: number): number {
|
||||||
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
|
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
|||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||||
|
|
||||||
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
||||||
export const MAX_TEXTURE_SIDE = 2048;
|
export const MAX_TEXTURE_SIDE = 2048;
|
||||||
@@ -51,7 +51,7 @@ export interface PreparedImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
||||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
async function prepareImage(file: File): Promise<PreparedImage> {
|
||||||
let bitmap: ImageBitmap;
|
let bitmap: ImageBitmap;
|
||||||
try {
|
try {
|
||||||
bitmap = await createImageBitmap(file);
|
bitmap = await createImageBitmap(file);
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export class TileCanvas extends Widget {
|
|||||||
const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h;
|
const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h;
|
||||||
if (this.followsFit && regrown) this.needsFit = true;
|
if (this.followsFit && regrown) this.needsFit = true;
|
||||||
if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) {
|
if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) {
|
||||||
// The largest step the board fits at, as in the scene view – no longer a free factor.
|
// The largest step the board fits at, as in the scene view.
|
||||||
const { w, h } = this.board;
|
const { w, h } = this.board;
|
||||||
const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h);
|
const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h);
|
||||||
const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio();
|
const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import type {
|
|||||||
} from '../../core/model/Asset';
|
} from '../../core/model/Asset';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
|
||||||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
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';
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
withoutPoint,
|
withoutPoint,
|
||||||
withSmoothToggled,
|
withSmoothToggled,
|
||||||
} from '../polygonEdit';
|
} from '../polygonEdit';
|
||||||
|
import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
|
||||||
import { type EditorState, shapeSelection } from '../state';
|
import { type EditorState, shapeSelection } from '../state';
|
||||||
import { drawChecker } from '../texture/checker';
|
import { drawChecker } from '../texture/checker';
|
||||||
import { SheetCanvas } from '../texture/SheetCanvas';
|
import { SheetCanvas } from '../texture/SheetCanvas';
|
||||||
@@ -63,12 +64,6 @@ export type VectorTool = 'select' | 'pen' | 'paint';
|
|||||||
/** Whose paint the paint tool shows: the fill's or the outline's. */
|
/** Whose paint the paint tool shows: the fill's or the outline's. */
|
||||||
export type PaintTarget = 'fill' | 'stroke';
|
export type PaintTarget = 'fill' | 'stroke';
|
||||||
|
|
||||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
|
||||||
const POINT_SIZE = 8;
|
|
||||||
/** Radius of a Bezier handle's knob, in screen pixels. */
|
|
||||||
const HANDLE_RADIUS = 4;
|
|
||||||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
|
||||||
const DRAG_THRESHOLD = 3;
|
|
||||||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||||||
const SNAP_STEP = 8;
|
const SNAP_STEP = 8;
|
||||||
|
|
||||||
@@ -771,33 +766,14 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
|||||||
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
||||||
private drawShape(shape: VectorShape | undefined): void {
|
private drawShape(shape: VectorShape | undefined): void {
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const half = POINT_SIZE / 2;
|
|
||||||
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
|
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
|
||||||
|
const toLocal = (x: number, y: number) => imageToLocal(this.view, x, y);
|
||||||
for (const [ringIndex, ring] of shape.rings.entries()) {
|
for (const [ringIndex, ring] of shape.rings.entries()) {
|
||||||
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
|
const screen = ringOutline(ring).map((p) => toLocal(p.x, p.y));
|
||||||
if (screen.length >= 2)
|
if (screen.length >= 2)
|
||||||
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
||||||
for (const [index, point] of ring.points.entries()) {
|
for (const [index, point] of ring.points.entries())
|
||||||
const at = imageToLocal(this.view, point.x, point.y);
|
drawPointGrip(this.overlay, point, picked?.ring === ringIndex && picked.index === index, toLocal);
|
||||||
const isPicked = picked?.ring === ringIndex && picked.index === index;
|
|
||||||
for (const side of ['in', 'out'] as const) {
|
|
||||||
const handle = handleAt(point, side);
|
|
||||||
if (!handle) continue;
|
|
||||||
const tip = imageToLocal(this.view, handle.x, handle.y);
|
|
||||||
this.overlay
|
|
||||||
.moveTo(at.x, at.y)
|
|
||||||
.lineTo(tip.x, tip.y)
|
|
||||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
|
||||||
this.overlay
|
|
||||||
.circle(tip.x, tip.y, HANDLE_RADIUS)
|
|
||||||
.fill(Theme.colors.viewport)
|
|
||||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
|
||||||
}
|
|
||||||
this.overlay
|
|
||||||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
|
||||||
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
|
||||||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,31 +3,18 @@ import { vectorCanvasSize } from '../../core/model/vectorart';
|
|||||||
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { SnapToggle } from '../graph/SnapToggle';
|
import { SnapToggle } from '../graph/SnapToggle';
|
||||||
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
import { POINT_BUTTON_WIDTH, PointKindButton } from '../PointKindButton';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
||||||
|
|
||||||
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
||||||
const INFO_WIDTH = 190;
|
const INFO_WIDTH = 190;
|
||||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
|
||||||
const POINT_WIDTH = 150;
|
|
||||||
|
|
||||||
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
|
||||||
straight: 'Straight',
|
|
||||||
corner: 'Corner',
|
|
||||||
smooth: 'Smooth',
|
|
||||||
symmetric: 'Symmetric',
|
|
||||||
};
|
|
||||||
|
|
||||||
function pointLabel(kind: PointKind | null): string {
|
|
||||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked
|
* The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked
|
||||||
* with the Paint tool, and the header has no room for both next to the size readout.
|
* with the Paint tool, and the header has no room for both next to the size readout.
|
||||||
*/
|
*/
|
||||||
const PAINT_TARGET_WIDTH = POINT_WIDTH;
|
const PAINT_TARGET_WIDTH = POINT_BUTTON_WIDTH;
|
||||||
|
|
||||||
const PAINT_TARGET_LABELS: Record<PaintTarget, string> = { fill: 'Fill', stroke: 'Outline' };
|
const PAINT_TARGET_LABELS: Record<PaintTarget, string> = { fill: 'Fill', stroke: 'Outline' };
|
||||||
|
|
||||||
@@ -47,17 +34,16 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
readonly paintButton: Button;
|
readonly paintButton: Button;
|
||||||
readonly paintTargetButton = new Dropdown(paintTargetLabel('fill'), { width: PAINT_TARGET_WIDTH });
|
readonly paintTargetButton = new Dropdown(paintTargetLabel('fill'), { width: PAINT_TARGET_WIDTH });
|
||||||
readonly snap = new SnapToggle(() => this.canvas);
|
readonly snap = new SnapToggle(() => this.canvas);
|
||||||
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
readonly pointButton = new PointKindButton(() => this.canvas);
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
||||||
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
|
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
|
||||||
this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') });
|
this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') });
|
||||||
this.pointButton.onClick = () => this.openPointMenu();
|
|
||||||
this.paintTargetButton.onClick = () => this.openPaintTargetMenu();
|
this.paintTargetButton.onClick = () => this.openPaintTargetMenu();
|
||||||
const canvas = new VectorArtCanvas(state);
|
const canvas = new VectorArtCanvas(state);
|
||||||
canvas.onPointPicked = () => this.syncPoint();
|
canvas.onPointPicked = () => this.pointButton.sync();
|
||||||
this.mount(canvas, [
|
this.mount(canvas, [
|
||||||
this.selectButton,
|
this.selectButton,
|
||||||
this.penButton,
|
this.penButton,
|
||||||
@@ -69,16 +55,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
this.setTool('select');
|
this.setTool('select');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The four types a point can be (#195); the one it is now is ticked. */
|
|
||||||
private openPointMenu(): void {
|
|
||||||
const items = POINT_KINDS.map((kind) => ({
|
|
||||||
label: POINT_KIND_LABELS[kind],
|
|
||||||
checked: () => this.canvas.pickedKind === kind,
|
|
||||||
onSelect: () => this.canvas.setPickedKind(kind),
|
|
||||||
}));
|
|
||||||
new Menu(items).openBelow(this.pointButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whose handles the paint tool shows (#186): the fill's or the outline's. */
|
/** Whose handles the paint tool shows (#186): the fill's or the outline's. */
|
||||||
private openPaintTargetMenu(): void {
|
private openPaintTargetMenu(): void {
|
||||||
const items = (['fill', 'stroke'] as const).map((target) => ({
|
const items = (['fill', 'stroke'] as const).map((target) => ({
|
||||||
@@ -94,12 +70,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
this.paintTargetButton.setText(paintTargetLabel(target));
|
this.paintTargetButton.setText(paintTargetLabel(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPoint(): void {
|
|
||||||
const kind = this.canvas.pickedKind;
|
|
||||||
this.pointButton.setText(pointLabel(kind));
|
|
||||||
this.pointButton.setEnabled(kind !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTool(tool: VectorTool): void {
|
setTool(tool: VectorTool): void {
|
||||||
this.canvas.setTool(tool);
|
this.canvas.setTool(tool);
|
||||||
this.selectButton.setToggled(tool === 'select');
|
this.selectButton.setToggled(tool === 'select');
|
||||||
@@ -136,6 +106,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
this.paintButton.setEnabled(asset !== null);
|
this.paintButton.setEnabled(asset !== null);
|
||||||
this.paintTargetButton.setEnabled(asset !== null);
|
this.paintTargetButton.setEnabled(asset !== null);
|
||||||
this.snap.setEnabled(asset !== null);
|
this.snap.setEnabled(asset !== null);
|
||||||
this.syncPoint();
|
this.pointButton.sync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export class GamePane extends VStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Auto and fit also say what they came to, once there is a camera to size. */
|
/** Auto and fit also say what they came to, once there is a camera to size. */
|
||||||
export function scaleLabel(mode: GameScale, factor: number | null): string {
|
function scaleLabel(mode: GameScale, factor: number | null): string {
|
||||||
if (typeof mode === 'number') return percent(mode);
|
if (typeof mode === 'number') return percent(mode);
|
||||||
const name = mode === 'auto' ? 'Auto' : 'Fit';
|
const name = mode === 'auto' ? 'Auto' : 'Fit';
|
||||||
return factor === null ? name : `${name} · ${percent(factor)}`;
|
return factor === null ? name : `${name} · ${percent(factor)}`;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { DrawItem } from '../../viewport/items';
|
|||||||
import { type View, worldToScreen } from '../../viewport/view';
|
import { type View, worldToScreen } from '../../viewport/view';
|
||||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||||
import { FramePreview } from '../framePreview';
|
import { FramePreview } from '../framePreview';
|
||||||
import type { SceneViewHost } from './sceneViewHost';
|
import { type SceneViewHost, selectedItem } from './sceneViewHost';
|
||||||
|
|
||||||
/** Screen pixels around a polygon's corner or edge that grab it. */
|
/** Screen pixels around a polygon's corner or edge that grab it. */
|
||||||
const CORNER_GRIP = 7;
|
const CORNER_GRIP = 7;
|
||||||
@@ -53,16 +53,9 @@ export class PolygonCornerEditor {
|
|||||||
return this.corner !== null;
|
return this.corner !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The polygon collider whose corners can be edited: the selected item, if it is one. */
|
||||||
* The polygon collider whose corners can be edited: the primary selection, if it is one and
|
|
||||||
* belongs to the view's holder – a placed container's content is edited in its template.
|
|
||||||
*/
|
|
||||||
private editedPolygon(): DrawItem | null {
|
private editedPolygon(): DrawItem | null {
|
||||||
const state = this.host.state;
|
const item = selectedItem(this.host);
|
||||||
const s = state.selection;
|
|
||||||
const holder = this.host.holder();
|
|
||||||
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
|
||||||
const item = this.host.items().find((i) => i.id === s.objectId);
|
|
||||||
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
|
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { objectRef } from '../../core/model/instance';
|
||||||
import type { ObjectHolder } from '../../core/model/Project';
|
import type { ObjectHolder } from '../../core/model/Project';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import type { DrawItem } from '../../viewport/items';
|
import type { DrawItem } from '../../viewport/items';
|
||||||
@@ -25,3 +26,15 @@ export interface SceneViewHost {
|
|||||||
scale(): number;
|
scale(): number;
|
||||||
redraw(): void;
|
redraw(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the view drew for the primary selection, if that is an object of the view's holder – a
|
||||||
|
* placed container's content is edited in its template.
|
||||||
|
*/
|
||||||
|
export function selectedItem(host: SceneViewHost): DrawItem | null {
|
||||||
|
const { state } = host;
|
||||||
|
const s = state.selection;
|
||||||
|
const holder = host.holder();
|
||||||
|
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
||||||
|
return host.items().find((i) => i.id === s.objectId) ?? null;
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
swept,
|
swept,
|
||||||
turned,
|
turned,
|
||||||
} from '../sizeGizmo';
|
} from '../sizeGizmo';
|
||||||
import type { SceneViewHost } from './sceneViewHost';
|
import { type SceneViewHost, selectedItem } from './sceneViewHost';
|
||||||
|
|
||||||
/** Screen pixels around a handle that grab it. */
|
/** Screen pixels around a handle that grab it. */
|
||||||
const GRIP = 7;
|
const GRIP = 7;
|
||||||
@@ -117,11 +117,7 @@ export class SizeGizmoEditor {
|
|||||||
|
|
||||||
/** The primary selection when it is an object with a turn and a scale this view can edit. */
|
/** The primary selection when it is an object with a turn and a scale this view can edit. */
|
||||||
private edited(): DrawItem | null {
|
private edited(): DrawItem | null {
|
||||||
const state = this.host.state;
|
const item = selectedItem(this.host);
|
||||||
const s = state.selection;
|
|
||||||
const holder = this.host.holder();
|
|
||||||
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
|
||||||
const item = this.host.items().find((i) => i.id === s.objectId);
|
|
||||||
return item && hasTransformProps(item.type) ? item : null;
|
return item && hasTransformProps(item.type) ? item : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ColliderShape } from '../../core/properties/collider';
|
|||||||
import { toPixels } from './engine';
|
import { toPixels } from './engine';
|
||||||
|
|
||||||
/** A pose that moved less than this (world units, radians) was not moved by anyone. */
|
/** A pose that moved less than this (world units, radians) was not moved by anyone. */
|
||||||
export const POSE_EPSILON = 1e-6;
|
const POSE_EPSILON = 1e-6;
|
||||||
|
|
||||||
export interface Pose {
|
export interface Pose {
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
adoptDeclarations,
|
adoptDeclarations,
|
||||||
asNumber,
|
|
||||||
buildSharedState,
|
buildSharedState,
|
||||||
carriedForward,
|
carriedForward,
|
||||||
declaredVariables,
|
declaredVariables,
|
||||||
emptySharedState,
|
emptySharedState,
|
||||||
type EntityHandle,
|
|
||||||
type Graph,
|
type Graph,
|
||||||
type GraphNode,
|
|
||||||
GraphInterpreter,
|
GraphInterpreter,
|
||||||
hierarchyRanks,
|
hierarchyRanks,
|
||||||
inEventOrder,
|
inEventOrder,
|
||||||
isHandleEnabled,
|
isHandleEnabled,
|
||||||
type NodeContext,
|
type NodeContext,
|
||||||
type PhysicsControl,
|
|
||||||
restartedAssets,
|
restartedAssets,
|
||||||
restoreSaved,
|
restoreSaved,
|
||||||
type SaveArea,
|
type SaveArea,
|
||||||
@@ -24,28 +20,38 @@ import {
|
|||||||
type Value,
|
type Value,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import { GraphCache } from '../../core/graph/graphIndex';
|
import { GraphCache } from '../../core/graph/graphIndex';
|
||||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
|
||||||
import { keyName } from '../../core/keys';
|
import { keyName } from '../../core/keys';
|
||||||
import { graphLookup } from '../../core/model/graphLookup';
|
import { graphLookup } from '../../core/model/graphLookup';
|
||||||
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
|
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||||
import { treeOrder } from '../../core/model/hierarchy';
|
import { treeOrder } from '../../core/model/hierarchy';
|
||||||
import { resolveObjects } from '../../core/model/instance';
|
import { resolveObjects } from '../../core/model/instance';
|
||||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||||
import { soundFileOf } from '../../core/model/sound';
|
|
||||||
import { parentChain } from '../../core/model/walk';
|
|
||||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
|
||||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
|
||||||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
import type { Rapier } from '../physics/engine';
|
import type { Rapier } from '../physics/engine';
|
||||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||||
|
import { behaviorContext, type TimeScaled } from './behaviorContext';
|
||||||
|
import {
|
||||||
|
animationFinishedPulses,
|
||||||
|
collisionEnterPulses,
|
||||||
|
collisionExitPulses,
|
||||||
|
collisionStayPulses,
|
||||||
|
type Instance,
|
||||||
|
keyDownPulses,
|
||||||
|
type Produced,
|
||||||
|
type Pulse,
|
||||||
|
pulsesOf,
|
||||||
|
timerPulses,
|
||||||
|
} from './behaviorPulses';
|
||||||
import { CollisionSystem } from './CollisionSystem';
|
import { CollisionSystem } from './CollisionSystem';
|
||||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids';
|
import type { CollisionSource } from './solids';
|
||||||
import { AnimationSystem } from './AnimationSystem';
|
import { AnimationSystem } from './AnimationSystem';
|
||||||
import type { Input } from './Input';
|
import type { Input } from './Input';
|
||||||
import type { System } from './System';
|
import type { System } from './System';
|
||||||
|
|
||||||
|
export type { TimeScaled } from './behaviorContext';
|
||||||
|
|
||||||
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
||||||
export const TRACE_FADE = 0.35;
|
export const TRACE_FADE = 0.35;
|
||||||
|
|
||||||
@@ -56,11 +62,6 @@ export interface Trace {
|
|||||||
values: Map<string, Record<string, Value>>;
|
values: Map<string, Record<string, Value>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Produced {
|
|
||||||
time: number;
|
|
||||||
values: Record<string, Value>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
|
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
|
||||||
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
|
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
|
||||||
@@ -73,42 +74,9 @@ interface StateChange {
|
|||||||
source: Instance | null;
|
source: Instance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_DYNAMIC_BODY =
|
|
||||||
'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.';
|
|
||||||
|
|
||||||
/** How often state events may cause the next move before the run is called endless. */
|
/** How often state events may cause the next move before the run is called endless. */
|
||||||
const MAX_STATE_ROUNDS = 100;
|
const MAX_STATE_ROUNDS = 100;
|
||||||
|
|
||||||
/** A collision pair seen from one behavior's body: its own collider and the one across. */
|
|
||||||
interface Side {
|
|
||||||
pair: CollisionPair;
|
|
||||||
own: Entity;
|
|
||||||
other: Entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */
|
|
||||||
interface Pulse {
|
|
||||||
instance: Instance;
|
|
||||||
node: GraphNode;
|
|
||||||
outputs?: Record<string, Value>;
|
|
||||||
port?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Instance {
|
|
||||||
entity: Entity;
|
|
||||||
interpreter: GraphInterpreter;
|
|
||||||
trace: Omit<Trace, 'values'>;
|
|
||||||
values: Map<Graph, Map<string, Produced>>;
|
|
||||||
/** Current state per state machine – this instance's alone, like its variables. */
|
|
||||||
states: Record<string, string>;
|
|
||||||
/** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
|
|
||||||
* therefore its own timers and variable state); the project is only read to find graphs.
|
|
||||||
*/
|
|
||||||
const ERROR_PREFIX = 'Error in behavior';
|
const ERROR_PREFIX = 'Error in behavior';
|
||||||
|
|
||||||
export interface GameSystemOptions {
|
export interface GameSystemOptions {
|
||||||
@@ -130,10 +98,6 @@ export interface GameSystemOptions {
|
|||||||
saves?: SaveArea | null;
|
saves?: SaveArea | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeScaled {
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||||||
export function addGameSystems(
|
export function addGameSystems(
|
||||||
world: World,
|
world: World,
|
||||||
@@ -179,6 +143,10 @@ export function behaviorError(world: World): string | undefined {
|
|||||||
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
|
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
|
||||||
|
* therefore its own timers and variable state); the project is only read to find graphs.
|
||||||
|
*/
|
||||||
export class BehaviorSystem implements System {
|
export class BehaviorSystem implements System {
|
||||||
/** A paused frame still ticks the behaviors that run while paused (see `update`). */
|
/** A paused frame still ticks the behaviors that run while paused (see `update`). */
|
||||||
readonly runsWhilePaused = true;
|
readonly runsWhilePaused = true;
|
||||||
@@ -511,17 +479,20 @@ export class BehaviorSystem implements System {
|
|||||||
const owner = i.entity.parent;
|
const owner = i.entity.parent;
|
||||||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||||||
};
|
};
|
||||||
|
const { input, collisions, animations } = this;
|
||||||
const kinds: (() => Pulse[])[] = [
|
const kinds: (() => Pulse[])[] = [
|
||||||
() =>
|
() =>
|
||||||
ticking.flatMap((i) => i.interpreter.eventNodes('OnKeyDown').flatMap((n) => this.keyDown(i, n, dt))),
|
ticking.flatMap((i) =>
|
||||||
() => pulsesOf(ticking, 'OnKeyUp', {}, (n) => this.input.justReleased.has(keyOf(n))),
|
i.interpreter.eventNodes('OnKeyDown').flatMap((n) => keyDownPulses(i, n, input, dt)),
|
||||||
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => this.input.heldKeys.has(keyOf(n))),
|
),
|
||||||
() => ticking.flatMap((i) => this.timers(i, dt)),
|
() => pulsesOf(ticking, 'OnKeyUp', {}, (n) => input.justReleased.has(keyOf(n))),
|
||||||
|
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
|
||||||
|
() => ticking.flatMap((i) => timerPulses(i, dt)),
|
||||||
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => this.collisionEnters(i))),
|
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => this.collisionStays(i, dt))),
|
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => this.collisionExits(i))),
|
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => this.animationsFinished(i))),
|
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||||||
() =>
|
() =>
|
||||||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||||||
() =>
|
() =>
|
||||||
@@ -545,129 +516,6 @@ export class BehaviorSystem implements System {
|
|||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
|
|
||||||
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
|
|
||||||
*/
|
|
||||||
private keyDown(instance: Instance, node: GraphNode, dt: number): Pulse[] {
|
|
||||||
const i = instance.interpreter;
|
|
||||||
const key = keyName(textOf(node.params.key ?? ''));
|
|
||||||
const stored = i.nodeState.get(node.id);
|
|
||||||
const { state, fires } = stepKeyRepeat(
|
|
||||||
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
|
||||||
{
|
|
||||||
pressed: this.input.justPressed.has(key),
|
|
||||||
down: this.input.heldKeys.has(key),
|
|
||||||
dt,
|
|
||||||
// The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds.
|
|
||||||
after: asNumber(node.params.repeatAfter ?? 0) / 1000,
|
|
||||||
interval: asNumber(node.params.repeatInterval ?? 0) / 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
|
|
||||||
else i.nodeState.delete(node.id);
|
|
||||||
return Array.from({ length: fires }, () => ({ instance, node }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private timers(instance: Instance, dt: number): Pulse[] {
|
|
||||||
const i = instance.interpreter;
|
|
||||||
const pulses: Pulse[] = [];
|
|
||||||
for (const node of i.eventNodes('OnTimer')) {
|
|
||||||
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
|
|
||||||
i.nodeState.set(node.id, state);
|
|
||||||
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
|
|
||||||
state.elapsed = (state.elapsed ?? 0) + dt;
|
|
||||||
// What is left after taking one interval is how long ago this tick was due. When one frame
|
|
||||||
// catches up several ticks, the first one was due longest ago and is the latest.
|
|
||||||
while (state.elapsed >= interval) {
|
|
||||||
state.elapsed -= interval;
|
|
||||||
pulses.push({ instance, node, outputs: { late: state.elapsed } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pulses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The collision pairs of one kind that concern this behavior: every pair in which one collider
|
|
||||||
* belongs to the behavior's body (its owner, or itself when it has none), with `other` being the
|
|
||||||
* body on the far side.
|
|
||||||
*/
|
|
||||||
private sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
|
|
||||||
const body = instance.entity.parent ?? instance.entity;
|
|
||||||
return pairs.flatMap((pair) => {
|
|
||||||
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
|
|
||||||
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private collisionEnters(instance: Instance): Pulse[] {
|
|
||||||
const sides = this.sides(instance, this.collisions?.enters ?? []);
|
|
||||||
return instance.interpreter
|
|
||||||
.eventNodes('OnCollisionEnter')
|
|
||||||
.flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stay fires every frame or, with an interval, once each time that long has passed since the pair
|
|
||||||
* met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone
|
|
||||||
* takes its clock with it.
|
|
||||||
*/
|
|
||||||
private collisionStays(instance: Instance, dt: number): Pulse[] {
|
|
||||||
const collisions = this.collisions;
|
|
||||||
if (!collisions) return [];
|
|
||||||
const interpreter = instance.interpreter;
|
|
||||||
const stays = this.sides(instance, collisions.stays);
|
|
||||||
const contacts = new Map<CollisionPair, Contact | null>();
|
|
||||||
const contactOf = (pair: CollisionPair) => {
|
|
||||||
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
|
|
||||||
return contacts.get(pair)!;
|
|
||||||
};
|
|
||||||
const pulses: Pulse[] = [];
|
|
||||||
for (const node of interpreter.eventNodes('OnCollisionStay')) {
|
|
||||||
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
|
||||||
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
|
||||||
const running: Record<string, number> = {};
|
|
||||||
for (const side of stays) {
|
|
||||||
let fires = 1;
|
|
||||||
if (interval > 0) {
|
|
||||||
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
|
||||||
let elapsed = (clocks[key] ?? 0) + dt;
|
|
||||||
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
|
||||||
running[key] = elapsed;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < fires; i++)
|
|
||||||
pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) });
|
|
||||||
}
|
|
||||||
if (interval > 0) interpreter.nodeState.set(node.id, running);
|
|
||||||
else interpreter.nodeState.delete(node.id);
|
|
||||||
}
|
|
||||||
return pulses;
|
|
||||||
}
|
|
||||||
|
|
||||||
private collisionExits(instance: Instance): Pulse[] {
|
|
||||||
const sides = this.sides(instance, this.collisions?.exits ?? []);
|
|
||||||
return instance.interpreter
|
|
||||||
.eventNodes('OnCollisionExit')
|
|
||||||
.flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side) })));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
|
||||||
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
|
||||||
*/
|
|
||||||
private animationsFinished(instance: Instance): Pulse[] {
|
|
||||||
const owner = instance.entity.parent;
|
|
||||||
const finished = (this.animations?.finished ?? []).filter(
|
|
||||||
({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner),
|
|
||||||
);
|
|
||||||
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
|
|
||||||
const wanted = textOf(node.params.animation ?? '');
|
|
||||||
return finished
|
|
||||||
.filter(({ animationId }) => wanted === '' || wanted === animationId)
|
|
||||||
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
runningIds(): string[] {
|
runningIds(): string[] {
|
||||||
return this.instances.map((i) => i.entity.objectId);
|
return this.instances.map((i) => i.entity.objectId);
|
||||||
}
|
}
|
||||||
@@ -696,19 +544,6 @@ export class BehaviorSystem implements System {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
|
||||||
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
|
||||||
* *is* – a node that says nothing therefore plays it as the project describes it.
|
|
||||||
*/
|
|
||||||
private playSound(assetId: string, volume: number, loop: boolean | undefined, owner: string): void {
|
|
||||||
const asset = findAssetOf(this.project, assetId, 'sound');
|
|
||||||
if (!asset?.file) return;
|
|
||||||
const props = resolveAssetProps(this.project, asset);
|
|
||||||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
|
||||||
this.audio.play(asset.file, level, loop ?? loopsOf(props), owner);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception
|
* Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception
|
||||||
* stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed
|
* stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed
|
||||||
@@ -741,201 +576,14 @@ export class BehaviorSystem implements System {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private context(instance: Instance, world: World, dt: number): NodeContext {
|
private context(instance: Instance, world: World, dt: number): NodeContext {
|
||||||
const { entity, states } = instance;
|
return behaviorContext(instance, world, dt, {
|
||||||
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
project: this.project,
|
||||||
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
input: this.input,
|
||||||
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
collisions: this.collisions,
|
||||||
return {
|
audio: this.audio,
|
||||||
self: entity,
|
clock: this.clock,
|
||||||
owner: entity.parent,
|
|
||||||
variables: entity.props,
|
|
||||||
states,
|
|
||||||
shared: this.sharedState,
|
shared: this.sharedState,
|
||||||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||||||
time: world.time,
|
});
|
||||||
dt,
|
|
||||||
findByName: (name) => world.findByName(name) ?? null,
|
|
||||||
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
|
||||||
isAlive: (target) => world.entities.some((e) => e === target),
|
|
||||||
findReferenced: (from, ref) => {
|
|
||||||
const found = resolve(from);
|
|
||||||
return (found && world.findReferenced(found, ref)) ?? null;
|
|
||||||
},
|
|
||||||
childByName: (parent, name) => {
|
|
||||||
const found = resolve(parent);
|
|
||||||
return (found && world.childByName(found, name)) ?? null;
|
|
||||||
},
|
|
||||||
objects: () => world.entities,
|
|
||||||
isKeyDown: (key) => this.input.isDown(key),
|
|
||||||
pointer: world.pointer,
|
|
||||||
hitTest: (target, x, y) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
return Boolean(found && world.hitTest(found, x, y));
|
|
||||||
},
|
|
||||||
isTouching: (a, b) => {
|
|
||||||
const ea = resolve(a);
|
|
||||||
const eb = resolve(b);
|
|
||||||
return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb));
|
|
||||||
},
|
|
||||||
moveAndSlide: (target, dx, dy) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
if (!found) return NOT_BLOCKED;
|
|
||||||
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
|
|
||||||
found.x += dx;
|
|
||||||
found.y += dy;
|
|
||||||
return NOT_BLOCKED;
|
|
||||||
},
|
|
||||||
raycast: (from, direction, length, { ignore, triggers, layers }) => {
|
|
||||||
const hit = this.collisions?.raycast(world, from, direction, length, {
|
|
||||||
ignore: resolve(ignore) ?? null,
|
|
||||||
triggers,
|
|
||||||
layers,
|
|
||||||
});
|
|
||||||
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
|
|
||||||
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
|
|
||||||
},
|
|
||||||
raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => {
|
|
||||||
const hits =
|
|
||||||
this.collisions?.raycastAll(
|
|
||||||
world,
|
|
||||||
from,
|
|
||||||
direction,
|
|
||||||
length,
|
|
||||||
{ ignore: resolve(ignore) ?? null, triggers, layers },
|
|
||||||
maxHits,
|
|
||||||
) ?? [];
|
|
||||||
// Drawn to the last thing it met, not to the first: that is how far this ray looked.
|
|
||||||
const last = hits[hits.length - 1];
|
|
||||||
const end = last ? last.point : pointAlong(from, direction, length);
|
|
||||||
world.noteRay(from, end, hits.length > 0);
|
|
||||||
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
|
|
||||||
},
|
|
||||||
ignoreOneWay: (target, seconds) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
if (found) world.ignoreOneWay(found, seconds);
|
|
||||||
},
|
|
||||||
isTilemap: (target) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
return found ? world.isTilemap(found) : false;
|
|
||||||
},
|
|
||||||
tileAt: (target, layer, cell) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
return found ? world.tileAt(found, layer, cell) : null;
|
|
||||||
},
|
|
||||||
setTile: (target, layer, cell, tileId) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
return found ? world.setTile(found, layer, cell, tileId) : false;
|
|
||||||
},
|
|
||||||
physics:
|
|
||||||
this.collisions instanceof PhysicsSystem
|
|
||||||
? physicsControl(world, this.collisions, resolve)
|
|
||||||
: undefined,
|
|
||||||
log: (message) => {
|
|
||||||
world.log.push(message);
|
|
||||||
// The `Log` node's whole purpose: the author wants this in the browser console.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[behavior ${entity.name}]`, message);
|
|
||||||
},
|
|
||||||
playSound: (assetId, volume, loop, detached) =>
|
|
||||||
this.playSound(assetId, volume, loop, detached ? '' : entity.id),
|
|
||||||
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
|
||||||
// only the empty id means "everything", which is what the node promises.
|
|
||||||
stopSound: (assetId) => {
|
|
||||||
const file = assetId === '' ? '' : soundFileOf(this.project, assetId);
|
|
||||||
if (assetId === '' || file) this.audio.stop(file);
|
|
||||||
},
|
|
||||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
|
||||||
destroy: (target, keepChildren) => {
|
|
||||||
const found = resolve(target);
|
|
||||||
if (!found) return [];
|
|
||||||
if (keepChildren) return world.removeKeepingChildren(found);
|
|
||||||
world.remove(found);
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
loadScene: (sceneId) => {
|
|
||||||
// An id naming no scene is dropped here rather than half way through the swap, where the
|
|
||||||
// run would already have torn down the world it is still standing in.
|
|
||||||
if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId;
|
|
||||||
else world.log.push(`Load scene: no scene ${sceneId}`);
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
world.stopRequested = true;
|
|
||||||
},
|
|
||||||
restart: () => {
|
|
||||||
world.restartRequested = true;
|
|
||||||
},
|
|
||||||
timeScale: () => this.clock?.scale ?? 1,
|
|
||||||
setTimeScale: (scale) => {
|
|
||||||
if (this.clock) this.clock.scale = scale;
|
|
||||||
else world.log.push(`Set time scale is ignored in the editor run (${scale})`);
|
|
||||||
},
|
|
||||||
isPaused: () => world.paused,
|
|
||||||
setPaused: (paused) => {
|
|
||||||
world.paused = paused;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every node of one event type in these instances, in hierarchy and then node order. */
|
|
||||||
function pulsesOf(
|
|
||||||
instances: readonly Instance[],
|
|
||||||
type: string,
|
|
||||||
outputs: Record<string, Value> = {},
|
|
||||||
filter?: (node: GraphNode) => boolean,
|
|
||||||
): Pulse[] {
|
|
||||||
return instances.flatMap((instance) =>
|
|
||||||
instance.interpreter
|
|
||||||
.eventNodes(type)
|
|
||||||
.filter((node) => !filter || filter(node))
|
|
||||||
.map((node) => ({ instance, node, outputs })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
|
|
||||||
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
|
|
||||||
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
|
||||||
if (contact !== undefined) {
|
|
||||||
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
|
||||||
const sign = own === pair.a ? 1 : -1;
|
|
||||||
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
|
|
||||||
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
|
|
||||||
outputs.impulse = contact?.impulse ?? 0;
|
|
||||||
outputs.pointX = contact?.point?.x ?? 0;
|
|
||||||
outputs.pointY = contact?.point?.y ?? 0;
|
|
||||||
}
|
|
||||||
return outputs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
|
||||||
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
|
||||||
const d = Math.hypot(direction.x, direction.y);
|
|
||||||
if (!(d > 0) || !(length > 0)) return from;
|
|
||||||
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
|
|
||||||
function physicsControl(
|
|
||||||
world: World,
|
|
||||||
physics: PhysicsSystem,
|
|
||||||
resolve: (handle: EntityHandle | null) => Entity | undefined,
|
|
||||||
): PhysicsControl {
|
|
||||||
const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => {
|
|
||||||
const found = resolve(handle);
|
|
||||||
if (!found) return false;
|
|
||||||
if (action(found)) return true;
|
|
||||||
physics.hint(world, found, NO_DYNAMIC_BODY);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
velocityOf: (handle) => {
|
|
||||||
const found = resolve(handle);
|
|
||||||
const v = found ? physics.velocityOf(found) : null;
|
|
||||||
return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null;
|
|
||||||
},
|
|
||||||
setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })),
|
|
||||||
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
|
|
||||||
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
|
|
||||||
setGravity: (x, y) => physics.setGravity(world, { x, y }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* What a behavior's graph reaches of the running game, as the `NodeContext` its nodes are handed:
|
||||||
|
* the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values.
|
||||||
|
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
|
||||||
|
*/
|
||||||
|
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
|
||||||
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
|
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||||
|
import { soundFileOf } from '../../core/model/sound';
|
||||||
|
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||||
|
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||||
|
import type { Entity } from '../Entity';
|
||||||
|
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||||
|
import type { World } from '../World';
|
||||||
|
import type { AudioSink } from './Audio';
|
||||||
|
import type { Instance } from './behaviorPulses';
|
||||||
|
import type { Input } from './Input';
|
||||||
|
import { bodyOf, type CollisionSource, NOT_BLOCKED } from './solids';
|
||||||
|
|
||||||
|
export interface TimeScaled {
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the running `BehaviorSystem` lends every context it makes. */
|
||||||
|
export interface ContextSources {
|
||||||
|
project: Project;
|
||||||
|
input: Input;
|
||||||
|
collisions: CollisionSource | null;
|
||||||
|
audio: AudioSink;
|
||||||
|
clock: TimeScaled | null;
|
||||||
|
shared: SharedState;
|
||||||
|
/** A graph moved one of this instance's machines; its events follow between exec flows. */
|
||||||
|
stateChanged(machineId: string, from: string, to: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_DYNAMIC_BODY =
|
||||||
|
'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.';
|
||||||
|
|
||||||
|
export function behaviorContext(
|
||||||
|
instance: Instance,
|
||||||
|
world: World,
|
||||||
|
dt: number,
|
||||||
|
run: ContextSources,
|
||||||
|
): NodeContext {
|
||||||
|
const { entity, states } = instance;
|
||||||
|
const { project, collisions, clock } = run;
|
||||||
|
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
||||||
|
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
||||||
|
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
||||||
|
return {
|
||||||
|
self: entity,
|
||||||
|
owner: entity.parent,
|
||||||
|
variables: entity.props,
|
||||||
|
states,
|
||||||
|
shared: run.shared,
|
||||||
|
stateChanged: (machineId, from, to) => run.stateChanged(machineId, from, to),
|
||||||
|
time: world.time,
|
||||||
|
dt,
|
||||||
|
findByName: (name) => world.findByName(name) ?? null,
|
||||||
|
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
||||||
|
isAlive: (target) => world.entities.some((e) => e === target),
|
||||||
|
findReferenced: (from, ref) => {
|
||||||
|
const found = resolve(from);
|
||||||
|
return (found && world.findReferenced(found, ref)) ?? null;
|
||||||
|
},
|
||||||
|
childByName: (parent, name) => {
|
||||||
|
const found = resolve(parent);
|
||||||
|
return (found && world.childByName(found, name)) ?? null;
|
||||||
|
},
|
||||||
|
objects: () => world.entities,
|
||||||
|
isKeyDown: (key) => run.input.isDown(key),
|
||||||
|
pointer: world.pointer,
|
||||||
|
hitTest: (target, x, y) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
return Boolean(found && world.hitTest(found, x, y));
|
||||||
|
},
|
||||||
|
isTouching: (a, b) => {
|
||||||
|
const ea = resolve(a);
|
||||||
|
const eb = resolve(b);
|
||||||
|
return Boolean(collisions && ea && eb && collisions.touching(world, ea, eb));
|
||||||
|
},
|
||||||
|
moveAndSlide: (target, dx, dy) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
if (!found) return NOT_BLOCKED;
|
||||||
|
if (collisions) return collisions.moveAndSlide(world, found, dx, dy);
|
||||||
|
found.x += dx;
|
||||||
|
found.y += dy;
|
||||||
|
return NOT_BLOCKED;
|
||||||
|
},
|
||||||
|
raycast: (from, direction, length, { ignore, triggers, layers }) => {
|
||||||
|
const hit = collisions?.raycast(world, from, direction, length, {
|
||||||
|
ignore: resolve(ignore) ?? null,
|
||||||
|
triggers,
|
||||||
|
layers,
|
||||||
|
});
|
||||||
|
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
|
||||||
|
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
|
||||||
|
},
|
||||||
|
raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => {
|
||||||
|
const hits =
|
||||||
|
collisions?.raycastAll(
|
||||||
|
world,
|
||||||
|
from,
|
||||||
|
direction,
|
||||||
|
length,
|
||||||
|
{ ignore: resolve(ignore) ?? null, triggers, layers },
|
||||||
|
maxHits,
|
||||||
|
) ?? [];
|
||||||
|
// Drawn to the last thing it met, not to the first: that is how far this ray looked.
|
||||||
|
const last = hits[hits.length - 1];
|
||||||
|
const end = last ? last.point : pointAlong(from, direction, length);
|
||||||
|
world.noteRay(from, end, hits.length > 0);
|
||||||
|
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
|
||||||
|
},
|
||||||
|
ignoreOneWay: (target, seconds) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
if (found) world.ignoreOneWay(found, seconds);
|
||||||
|
},
|
||||||
|
isTilemap: (target) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
return found ? world.isTilemap(found) : false;
|
||||||
|
},
|
||||||
|
tileAt: (target, layer, cell) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
return found ? world.tileAt(found, layer, cell) : null;
|
||||||
|
},
|
||||||
|
setTile: (target, layer, cell, tileId) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
return found ? world.setTile(found, layer, cell, tileId) : false;
|
||||||
|
},
|
||||||
|
physics: collisions instanceof PhysicsSystem ? physicsControl(world, collisions, resolve) : undefined,
|
||||||
|
log: (message) => {
|
||||||
|
world.log.push(message);
|
||||||
|
// The `Log` node's whole purpose: the author wants this in the browser console.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[behavior ${entity.name}]`, message);
|
||||||
|
},
|
||||||
|
playSound: (assetId, volume, loop, detached) =>
|
||||||
|
playSound(run, assetId, volume, loop, detached ? '' : entity.id),
|
||||||
|
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
||||||
|
// only the empty id means "everything", which is what the node promises.
|
||||||
|
stopSound: (assetId) => {
|
||||||
|
const file = assetId === '' ? '' : soundFileOf(project, assetId);
|
||||||
|
if (assetId === '' || file) run.audio.stop(file);
|
||||||
|
},
|
||||||
|
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||||
|
destroy: (target, keepChildren) => {
|
||||||
|
const found = resolve(target);
|
||||||
|
if (!found) return [];
|
||||||
|
if (keepChildren) return world.removeKeepingChildren(found);
|
||||||
|
world.remove(found);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
loadScene: (sceneId) => {
|
||||||
|
// An id naming no scene is dropped here rather than half way through the swap, where the
|
||||||
|
// run would already have torn down the world it is still standing in.
|
||||||
|
if (findScene(project, sceneId)) world.loadSceneRequested = sceneId;
|
||||||
|
else world.log.push(`Load scene: no scene ${sceneId}`);
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
world.stopRequested = true;
|
||||||
|
},
|
||||||
|
restart: () => {
|
||||||
|
world.restartRequested = true;
|
||||||
|
},
|
||||||
|
timeScale: () => clock?.scale ?? 1,
|
||||||
|
setTimeScale: (scale) => {
|
||||||
|
if (clock) clock.scale = scale;
|
||||||
|
else world.log.push(`Set time scale is ignored in the editor run (${scale})`);
|
||||||
|
},
|
||||||
|
isPaused: () => world.paused,
|
||||||
|
setPaused: (paused) => {
|
||||||
|
world.paused = paused;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
||||||
|
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
||||||
|
* *is* – a node that says nothing therefore plays it as the project describes it.
|
||||||
|
*/
|
||||||
|
function playSound(
|
||||||
|
{ project, audio }: ContextSources,
|
||||||
|
assetId: string,
|
||||||
|
volume: number,
|
||||||
|
loop: boolean | undefined,
|
||||||
|
owner: string,
|
||||||
|
): void {
|
||||||
|
const asset = findAssetOf(project, assetId, 'sound');
|
||||||
|
if (!asset?.file) return;
|
||||||
|
const props = resolveAssetProps(project, asset);
|
||||||
|
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||||||
|
audio.play(asset.file, level, loop ?? loopsOf(props), owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
||||||
|
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
||||||
|
const d = Math.hypot(direction.x, direction.y);
|
||||||
|
if (!(d > 0) || !(length > 0)) return from;
|
||||||
|
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
|
||||||
|
function physicsControl(
|
||||||
|
world: World,
|
||||||
|
physics: PhysicsSystem,
|
||||||
|
resolve: (handle: EntityHandle | null) => Entity | undefined,
|
||||||
|
): PhysicsControl {
|
||||||
|
const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => {
|
||||||
|
const found = resolve(handle);
|
||||||
|
if (!found) return false;
|
||||||
|
if (action(found)) return true;
|
||||||
|
physics.hint(world, found, NO_DYNAMIC_BODY);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
velocityOf: (handle) => {
|
||||||
|
const found = resolve(handle);
|
||||||
|
const v = found ? physics.velocityOf(found) : null;
|
||||||
|
return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null;
|
||||||
|
},
|
||||||
|
setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })),
|
||||||
|
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
|
||||||
|
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
|
||||||
|
setGravity: (x, y) => physics.setGravity(world, { x, y }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* A running behavior, and the pulses the events of one frame send into it: one per event node that
|
||||||
|
* fires, with what the event hands on. `BehaviorSystem` decides when each kind is gathered and runs
|
||||||
|
* them in their order.
|
||||||
|
*/
|
||||||
|
import { asNumber, type Graph, type GraphInterpreter, type GraphNode, type Value } from '../../core/graph';
|
||||||
|
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||||
|
import { keyName } from '../../core/keys';
|
||||||
|
import { textOf } from '../../core/model/Properties';
|
||||||
|
import { parentChain } from '../../core/model/walk';
|
||||||
|
import type { Entity } from '../Entity';
|
||||||
|
import type { AnimationSystem } from './AnimationSystem';
|
||||||
|
import type { Input } from './Input';
|
||||||
|
import { bodyOf, type CollisionPair, type CollisionSource, type Contact } from './solids';
|
||||||
|
|
||||||
|
/** What one node last handed on, and when – the graph tab shows the newest of every instance. */
|
||||||
|
export interface Produced {
|
||||||
|
time: number;
|
||||||
|
values: Record<string, Value>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Instance {
|
||||||
|
entity: Entity;
|
||||||
|
interpreter: GraphInterpreter;
|
||||||
|
/** Recency of exec edges and nodes, keyed by id, in world time. */
|
||||||
|
trace: { edges: Map<string, number>; nodes: Map<string, number> };
|
||||||
|
values: Map<Graph, Map<string, Produced>>;
|
||||||
|
/** Current state per state machine – this instance's alone, like its variables. */
|
||||||
|
states: Record<string, string>;
|
||||||
|
/** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */
|
||||||
|
export interface Pulse {
|
||||||
|
instance: Instance;
|
||||||
|
node: GraphNode;
|
||||||
|
outputs?: Record<string, Value>;
|
||||||
|
port?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collision pair seen from one behavior's body: its own collider and the one across. */
|
||||||
|
interface Side {
|
||||||
|
pair: CollisionPair;
|
||||||
|
own: Entity;
|
||||||
|
other: Entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every node of one event type in these instances, in hierarchy and then node order. */
|
||||||
|
export function pulsesOf(
|
||||||
|
instances: readonly Instance[],
|
||||||
|
type: string,
|
||||||
|
outputs: Record<string, Value> = {},
|
||||||
|
filter?: (node: GraphNode) => boolean,
|
||||||
|
): Pulse[] {
|
||||||
|
return instances.flatMap((instance) =>
|
||||||
|
instance.interpreter
|
||||||
|
.eventNodes(type)
|
||||||
|
.filter((node) => !filter || filter(node))
|
||||||
|
.map((node) => ({ instance, node, outputs })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
|
||||||
|
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
|
||||||
|
*/
|
||||||
|
export function keyDownPulses(instance: Instance, node: GraphNode, input: Input, dt: number): Pulse[] {
|
||||||
|
const i = instance.interpreter;
|
||||||
|
const key = keyName(textOf(node.params.key ?? ''));
|
||||||
|
const stored = i.nodeState.get(node.id);
|
||||||
|
const { state, fires } = stepKeyRepeat(
|
||||||
|
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
||||||
|
{
|
||||||
|
pressed: input.justPressed.has(key),
|
||||||
|
down: input.heldKeys.has(key),
|
||||||
|
dt,
|
||||||
|
// The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds.
|
||||||
|
after: asNumber(node.params.repeatAfter ?? 0) / 1000,
|
||||||
|
interval: asNumber(node.params.repeatInterval ?? 0) / 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
|
||||||
|
else i.nodeState.delete(node.id);
|
||||||
|
return Array.from({ length: fires }, () => ({ instance, node }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timerPulses(instance: Instance, dt: number): Pulse[] {
|
||||||
|
const i = instance.interpreter;
|
||||||
|
const pulses: Pulse[] = [];
|
||||||
|
for (const node of i.eventNodes('OnTimer')) {
|
||||||
|
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
|
||||||
|
i.nodeState.set(node.id, state);
|
||||||
|
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
|
||||||
|
state.elapsed = (state.elapsed ?? 0) + dt;
|
||||||
|
// What is left after taking one interval is how long ago this tick was due. When one frame
|
||||||
|
// catches up several ticks, the first one was due longest ago and is the latest.
|
||||||
|
while (state.elapsed >= interval) {
|
||||||
|
state.elapsed -= interval;
|
||||||
|
pulses.push({ instance, node, outputs: { late: state.elapsed } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pulses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collision pairs of one kind that concern this behavior: every pair in which one collider
|
||||||
|
* belongs to the behavior's body (its owner, or itself when it has none), with `other` being the
|
||||||
|
* body on the far side.
|
||||||
|
*/
|
||||||
|
function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
|
||||||
|
const body = instance.entity.parent ?? instance.entity;
|
||||||
|
return pairs.flatMap((pair) => {
|
||||||
|
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
|
||||||
|
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collisionEnterPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
|
||||||
|
const entered = sides(instance, collisions?.enters ?? []);
|
||||||
|
return instance.interpreter
|
||||||
|
.eventNodes('OnCollisionEnter')
|
||||||
|
.flatMap((node) => entered.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stay fires every frame or, with an interval, once each time that long has passed since the pair
|
||||||
|
* met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone
|
||||||
|
* takes its clock with it.
|
||||||
|
*/
|
||||||
|
export function collisionStayPulses(
|
||||||
|
instance: Instance,
|
||||||
|
collisions: CollisionSource | null,
|
||||||
|
dt: number,
|
||||||
|
): Pulse[] {
|
||||||
|
if (!collisions) return [];
|
||||||
|
const interpreter = instance.interpreter;
|
||||||
|
const stays = sides(instance, collisions.stays);
|
||||||
|
const contacts = new Map<CollisionPair, Contact | null>();
|
||||||
|
const contactOf = (pair: CollisionPair) => {
|
||||||
|
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
|
||||||
|
return contacts.get(pair)!;
|
||||||
|
};
|
||||||
|
const pulses: Pulse[] = [];
|
||||||
|
for (const node of interpreter.eventNodes('OnCollisionStay')) {
|
||||||
|
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
||||||
|
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
||||||
|
const running: Record<string, number> = {};
|
||||||
|
for (const side of stays) {
|
||||||
|
let fires = 1;
|
||||||
|
if (interval > 0) {
|
||||||
|
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
||||||
|
let elapsed = (clocks[key] ?? 0) + dt;
|
||||||
|
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
||||||
|
running[key] = elapsed;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < fires; i++)
|
||||||
|
pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) });
|
||||||
|
}
|
||||||
|
if (interval > 0) interpreter.nodeState.set(node.id, running);
|
||||||
|
else interpreter.nodeState.delete(node.id);
|
||||||
|
}
|
||||||
|
return pulses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collisionExitPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
|
||||||
|
const left = sides(instance, collisions?.exits ?? []);
|
||||||
|
return instance.interpreter
|
||||||
|
.eventNodes('OnCollisionExit')
|
||||||
|
.flatMap((node) => left.map((side) => ({ instance, node, outputs: sideOutputs(side) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
||||||
|
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
||||||
|
*/
|
||||||
|
export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] {
|
||||||
|
const owner = instance.entity.parent;
|
||||||
|
const finished = (animations?.finished ?? []).filter(
|
||||||
|
({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner),
|
||||||
|
);
|
||||||
|
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
|
||||||
|
const wanted = textOf(node.params.animation ?? '');
|
||||||
|
return finished
|
||||||
|
.filter(({ animationId }) => wanted === '' || wanted === animationId)
|
||||||
|
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
|
||||||
|
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
|
||||||
|
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
||||||
|
if (contact !== undefined) {
|
||||||
|
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
||||||
|
const sign = own === pair.a ? 1 : -1;
|
||||||
|
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
|
||||||
|
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
|
||||||
|
outputs.impulse = contact?.impulse ?? 0;
|
||||||
|
outputs.pointX = contact?.point?.x ?? 0;
|
||||||
|
outputs.pointY = contact?.point?.y ?? 0;
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { FileLibrary } from './FileLibrary';
|
|||||||
* Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame
|
* Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame
|
||||||
* or two and then reflows when the face lands.
|
* or two and then reflows when the face lands.
|
||||||
*/
|
*/
|
||||||
export class FontLibrary extends FileLibrary<FontFace> {
|
class FontLibrary extends FileLibrary<FontFace> {
|
||||||
private readonly ready = new Set<string>();
|
private readonly ready = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { gameClock } from '../core/time';
|
|||||||
* contract the `ShaderLibrary` keeps, including its own freeze so a test can hold particle time
|
* contract the `ShaderLibrary` keeps, including its own freeze so a test can hold particle time
|
||||||
* still without freezing the shaders too.
|
* still without freezing the shaders too.
|
||||||
*/
|
*/
|
||||||
export class ParticleLibrary {
|
class ParticleLibrary {
|
||||||
private readonly builds = new Map<string, { fingerprint: string; build: ParticlesBuild }>();
|
private readonly builds = new Map<string, { fingerprint: string; build: ParticlesBuild }>();
|
||||||
private frozenAt: number | null = null;
|
private frozenAt: number | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ShaderSource } from './shaders';
|
|||||||
* Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is
|
* Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is
|
||||||
* edited in place, so the cache keys on a fingerprint of what the fragment depends on.
|
* edited in place, so the cache keys on a fingerprint of what the fragment depends on.
|
||||||
*/
|
*/
|
||||||
export class ShaderLibrary {
|
class ShaderLibrary {
|
||||||
private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>();
|
private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>();
|
||||||
/** How often a graph went through the compiler – what the checks read to prove a move did not. */
|
/** How often a graph went through the compiler – what the checks read to prove a move did not. */
|
||||||
compiles = 0;
|
compiles = 0;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { gameClock } from '../core/time';
|
|||||||
import { type View, worldBounds, worldToScreen } from './view';
|
import { type View, worldBounds, worldToScreen } from './view';
|
||||||
|
|
||||||
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
|
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
|
||||||
export const CLIP_GHOST_ALPHA = 0.3;
|
const CLIP_GHOST_ALPHA = 0.3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a view draws from: the project and word of when it changed. The editor's state is one; a
|
* What a view draws from: the project and word of when it changed. The editor's state is one; a
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { disposeParticleShader, type ShaderSource, syncParticleShader } from './
|
|||||||
|
|
||||||
const DEG = Math.PI / 180;
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
export function emitterSettings(props: PropertyBag): EmitterSettings {
|
function emitterSettings(props: PropertyBag): EmitterSettings {
|
||||||
return {
|
return {
|
||||||
mode: emitModeOf(props),
|
mode: emitModeOf(props),
|
||||||
rate: numberOf(props[RATE_KEY], 20),
|
rate: numberOf(props[RATE_KEY], 20),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function textResolution(scale: number, smooth: boolean): number {
|
|||||||
* Chrome on Windows smooths canvas glyphs sideways (DirectWrite) whatever the filter is afterwards;
|
* Chrome on Windows smooths canvas glyphs sideways (DirectWrite) whatever the filter is afterwards;
|
||||||
* at this factor the smoothing is an eighth of a font pixel and drowns in the block around it.
|
* at this factor the smoothing is an eighth of a font pixel and drowns in the block around it.
|
||||||
*/
|
*/
|
||||||
export const SHARP_SUPERSAMPLE = 8;
|
const SHARP_SUPERSAMPLE = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Glyphs rasterised `factor` times too fine, brought down to one texel per `factor`×`factor` block:
|
* Glyphs rasterised `factor` times too fine, brought down to one texel per `factor`×`factor` block:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function wheelNotches(deltaY: number, deltaMode: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
|
/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
|
||||||
export const WHEEL_STEP = 50;
|
const WHEEL_STEP = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel
|
* Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel
|
||||||
|
|||||||
@@ -271,18 +271,17 @@ describe('a struct typed by a Struct asset', () => {
|
|||||||
f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Name', kind: 'text' }));
|
f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Name', kind: 'text' }));
|
||||||
f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'enabled', kind: 'boolean' }));
|
f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'enabled', kind: 'boolean' }));
|
||||||
f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', enabled: true }));
|
f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', enabled: true }));
|
||||||
const make = new MakeStructTypeCommand(chest.id, 'drop', []);
|
f.stack.execute(new MakeStructTypeCommand(chest.id, 'drop', []));
|
||||||
f.stack.execute(make);
|
const type = f.project.assets.at(-1) as StructAsset;
|
||||||
const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset;
|
|
||||||
expect(type).toMatchObject({ type: 'struct', name: 'drop', props: { Name: 'Gem', enabled: true } });
|
expect(type).toMatchObject({ type: 'struct', name: 'drop', props: { Name: 'Gem', enabled: true } });
|
||||||
expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['Name:text', 'enabled:boolean']);
|
expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['Name:text', 'enabled:boolean']);
|
||||||
expect(drop(chest).structType).toBe(type.id);
|
expect(drop(chest).structType).toBe(type.id);
|
||||||
expect(chest.props.drop).toEqual({ Name: 'Gem', enabled: true });
|
expect(chest.props.drop).toEqual({ Name: 'Gem', enabled: true });
|
||||||
f.stack.undo();
|
f.stack.undo();
|
||||||
expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false);
|
expect(f.project.assets.some((a) => a.id === type.id)).toBe(false);
|
||||||
expect(drop(chest).structType).toBeUndefined();
|
expect(drop(chest).structType).toBeUndefined();
|
||||||
f.stack.redo();
|
f.stack.redo();
|
||||||
expect(drop(chest).structType).toBe(make.assetId);
|
expect(drop(chest).structType).toBe(type.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model
|
|||||||
import { translation } from '../../src/core/model/matrix';
|
import { translation } from '../../src/core/model/matrix';
|
||||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||||
import { findAsset } from '../../src/core/model/Project';
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
import { TILE_BOUNCINESS_KEY, TILE_FRICTION_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
import {
|
||||||
|
TILE_BOUNCINESS_KEY,
|
||||||
|
TILE_FRICTION_KEY,
|
||||||
|
TILE_ONE_WAY_ANGLE_KEY,
|
||||||
|
TILE_ONE_WAY_KEY,
|
||||||
|
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 { solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision';
|
import { oneWayAngles, solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision';
|
||||||
import {
|
import {
|
||||||
colliderFrame,
|
colliderFrame,
|
||||||
colliderShape,
|
colliderShape,
|
||||||
@@ -255,6 +261,18 @@ describe('the surface of a solid tile (#193)', () => {
|
|||||||
{ x: 0, y: 0, w: 2, h: 1 },
|
{ x: 0, y: 0, w: 2, h: 1 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a tile that does not block brings no surface and no one-way angle of its own', () => {
|
||||||
|
const s = tileScene();
|
||||||
|
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_SOLID_KEY, false));
|
||||||
|
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_KEY, true));
|
||||||
|
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_ANGLE_KEY, 30));
|
||||||
|
s.paint([[0, 0]], s.wallId);
|
||||||
|
s.paint([[1, 0]], s.iceId);
|
||||||
|
const asset = s.mapAsset();
|
||||||
|
expect(tileMaterials(s.project, asset)).toEqual([{ friction: 0.5, bounciness: 0 }]);
|
||||||
|
expect(oneWayAngles(s.project, asset)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => {
|
describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => {
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ import {
|
|||||||
TILEMAP_COLUMNS_KEY,
|
TILEMAP_COLUMNS_KEY,
|
||||||
TILEMAP_ROWS_KEY,
|
TILEMAP_ROWS_KEY,
|
||||||
TILEMAP_TILE_KEY,
|
TILEMAP_TILE_KEY,
|
||||||
tilemapTileIds,
|
|
||||||
tilemapSize,
|
tilemapSize,
|
||||||
} from '../../src/core/model/tilemap';
|
} from '../../src/core/model/tilemap';
|
||||||
import { tilemapsUsing } from '../../src/core/model/usages';
|
|
||||||
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
|
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
|
||||||
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
|
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
|
||||||
import { makeProject, roundTrip } from '../helpers';
|
import { makeProject, roundTrip } from '../helpers';
|
||||||
@@ -445,14 +443,6 @@ describe('a tilemap in the document', () => {
|
|||||||
expect(back.layers).toHaveLength(1);
|
expect(back.layers).toHaveLength(1);
|
||||||
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
|
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts as a use of the tiles painted on it', () => {
|
|
||||||
const f = makeTilemap();
|
|
||||||
expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0);
|
|
||||||
f.paint([{ x: 0, y: 0 }]);
|
|
||||||
expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]);
|
|
||||||
expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the mesh a layer becomes', () => {
|
describe('the mesh a layer becomes', () => {
|
||||||
|
|||||||
@@ -164,9 +164,8 @@ describe('a list typed by a Struct asset', () => {
|
|||||||
{ Name: 'Gem', Amount: 2 },
|
{ Name: 'Gem', Amount: 2 },
|
||||||
{ Name: 'Axe', Rare: true },
|
{ Name: 'Axe', Rare: true },
|
||||||
]);
|
]);
|
||||||
const make = new MakeListItemTypeCommand(target(chest));
|
f.stack.execute(new MakeListItemTypeCommand(target(chest)));
|
||||||
f.stack.execute(make);
|
const type = f.project.assets.at(-1) as StructAsset;
|
||||||
const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset;
|
|
||||||
expect(type).toMatchObject({
|
expect(type).toMatchObject({
|
||||||
type: 'struct',
|
type: 'struct',
|
||||||
name: 'loots',
|
name: 'loots',
|
||||||
@@ -182,7 +181,7 @@ describe('a list typed by a Struct asset', () => {
|
|||||||
{ Name: 'Axe', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id },
|
{ Name: 'Axe', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id },
|
||||||
]);
|
]);
|
||||||
f.stack.undo();
|
f.stack.undo();
|
||||||
expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false);
|
expect(f.project.assets.some((a) => a.id === type.id)).toBe(false);
|
||||||
expect(chest.props.loots).toEqual([
|
expect(chest.props.loots).toEqual([
|
||||||
{ Name: 'Gem', Amount: 2 },
|
{ Name: 'Gem', Amount: 2 },
|
||||||
{ Name: 'Axe', Rare: true },
|
{ Name: 'Axe', Rare: true },
|
||||||
|
|||||||
Reference in new issue
Block a user