Files
feuerwerk/src/core/graph/sharedState.ts
T
adminandClaude Opus 5.5 056a622524
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s
Refactoring round 6: shared sheet canvas, fewer copies (#85)
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).

- SheetCanvas is the new base of the texture, pixel art and vector art
  canvases. It holds background, mask, hint, panning, wheel zoom, fit,
  view save and restore and the screen mapping. Pixel art keeps its
  whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
  animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
  contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
  commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
  GeneratedPictures.has and SnapToggle.snapping. Tests now check the
  same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
  The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
  wiki pages.

Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
  has not been called since #128. The rectangle tool therefore records
  a no-op undo step when it paints the same tile over itself. Left as
  is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
  and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
  could become 10 without a red test. tests/core/raycast.test.ts now
  covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
  #164. The sum is now 1310 (1308 + 2), computed.

Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
  both new pan checks; unclamped mix fails 1 test, no blue channel 3;
  coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
  bent ray length 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 06:57:43 +02:00

136 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PropertyBag } from '../model/Properties';
import type { Project } from '../model/Project';
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
import type { NodeContext, SharedState } from './NodeContext';
import { startStateOf } from './stateMachine';
export function emptySharedState(): SharedState {
return { variables: {}, states: {} };
}
/** What every Variables asset declares right now: the value set on the asset, else the default. */
export function declaredVariables(project: Project): Record<string, PropertyBag> {
const declared: Record<string, PropertyBag> = {};
for (const asset of project.assets) {
if (asset.type !== 'variables') continue;
const bag: PropertyBag = {};
for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default;
declared[asset.id] = bag;
}
return declared;
}
/**
* Built once per run, from the declarations rather than from any instance: a Variables asset holds
* its values whether or not a graph ever reads them, and nothing is written back when the run ends.
*/
export function buildSharedState(project: Project): SharedState {
const shared: SharedState = { variables: declaredVariables(project), states: {} };
for (const asset of project.assets)
if (asset.type === 'statemachine' && isShared(asset))
shared.states[asset.id] = startStateOf(asset.graph, asset.start);
return shared;
}
/**
* The state the next scene of the same run starts from: what is `global` travels as it stands,
* what is `scene` is built again from the declarations. Fresh bags rather than the carried ones,
* so the world before it cannot still be writing into what this one reads.
*/
export function carriedForward(project: Project, carried: Readonly<SharedState>): SharedState {
const fresh = buildSharedState(project);
const restarted = restartedAssets(project);
const next: SharedState = { variables: {}, states: {} };
for (const [id, bag] of Object.entries(fresh.variables))
next.variables[id] = restarted.has(id) ? bag : (carried.variables[id] ?? bag);
for (const [id, state] of Object.entries(fresh.states))
next.states[id] = restarted.has(id) ? state : (carried.states[id] ?? state);
return next;
}
/**
* The assets a scene swap starts over rather than hands on, by id: the `scene` Variables assets and
* the `scene` machines. Also what tells the new world which machines to announce an entry for.
*/
export function restartedAssets(project: Project): Set<string> {
const ids = new Set<string>();
for (const asset of project.assets) {
if (asset.type === 'variables' && sharedScopeOf(asset) === 'scene') ids.add(asset.id);
if (asset.type === 'statemachine' && isPerScene(asset)) ids.add(asset.id);
}
return ids;
}
/**
* The start state of every machine that has a usable one, keyed by asset id. A machine without one
* is absent, which is what keeps "no start" and "starts in a deleted state" the same thing.
*/
export function startStates(project: Project): Record<string, string> {
const starts: Record<string, string> = {};
for (const asset of project.assets) {
if (asset.type !== 'statemachine') continue;
const start = startStateOf(asset.graph, asset.start);
if (start) starts[asset.id] = start;
}
return starts;
}
/**
* Moves edits made to the declarations during a run into the values it shares. A key is taken over
* only where the document changed it since `seen`, which is what leaves a value a graph has moved
* on alone – the panel can show the running value and set it without the two fighting.
*/
export function adoptDeclarations(
live: Record<string, PropertyBag>,
seen: Record<string, PropertyBag>,
now: Record<string, PropertyBag>,
): void {
for (const [assetId, declared] of Object.entries(now)) {
const bag = live[assetId];
if (!bag) {
live[assetId] = { ...declared };
continue;
}
const before = seen[assetId] ?? {};
for (const [key, value] of Object.entries(declared))
if (!(key in bag) || before[key] !== value) bag[key] = value;
for (const key of Object.keys(bag)) if (!(key in declared)) delete bag[key];
}
for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId];
}
/**
* The values a `Variables` node reads and writes: those of the asset it names, else the behavior's
* own. A name that no asset answers to is a fresh bag, so a node left pointing at a deleted asset
* writes nowhere instead of into the behavior it sits in.
*/
export function variableBag(ctx: NodeContext, source: string): PropertyBag {
if (!source) return ctx.variables;
return ctx.shared.variables[source] ?? {};
}
/** Where a machine's current state is kept: shared unless the machine is `local`, else per instance. */
function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
}
/** The current state of a machine, `''` when it is in none. */
export function currentState(ctx: NodeContext, machineId: string): string {
return (machineId && stateBag(ctx, machineId)[machineId]) || '';
}
/**
* The one way a machine moves: writes the new state and reports the move. Into the state it is
* already in is no move – nothing is written, and nothing fires, which is what keeps a `Set state`
* run every frame from firing `entered` every frame. `reenter` is the exception a transition drawn
* back into its own state asks for: the state is left and entered again, both events included.
*/
export function setState(ctx: NodeContext, machineId: string, next: string, reenter = false): void {
if (!machineId) return;
const bag = stateBag(ctx, machineId);
const previous = bag[machineId] ?? '';
if (previous === next && !reenter) return;
bag[machineId] = next;
ctx.stateChanged(machineId, previous, next);
}