castle-web-cli 0.4.77 → 0.4.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/ide.js +2 -0
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +83 -4
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +95 -17
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
- package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +263 -27
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -113,6 +113,45 @@ export function CanvasSizeBar({ width, height, onResize }) {
|
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// The 3 corner-radius presets offered by CornerRadiusBar. 0 is sharp/pixel;
|
|
117
|
+
// the other two are "nice" rounded amounts. The file format itself isn't
|
|
118
|
+
// limited to these three (any value up to MAX_CORNER_RADIUS is valid — see
|
|
119
|
+
// pxart.js) — this is just the curated set the editor's segmented control
|
|
120
|
+
// exposes, so hand-picking a value never means hunting a slider.
|
|
121
|
+
const CORNER_RADIUS_STEPS = [
|
|
122
|
+
{ value: 0, label: '0' },
|
|
123
|
+
{ value: 0.25, label: '¼' },
|
|
124
|
+
{ value: 0.5, label: '½' },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
// Corner-radius segmented control, meant to sit next to CanvasSizeBar above
|
|
128
|
+
// the artboard. Sets the sprite's file-level `cornerRadius` field (a property
|
|
129
|
+
// of the .pxart asset — see docs/pxart-format.md — not a per-actor Sprite
|
|
130
|
+
// prop). `radius` is a plain number (0 = sharp/pixel corners); highlights
|
|
131
|
+
// whichever preset it exactly matches, or none if it's a value from outside
|
|
132
|
+
// this set (e.g. hand-edited JSON).
|
|
133
|
+
export function CornerRadiusBar({ radius, onChange }) {
|
|
134
|
+
return (
|
|
135
|
+
<div className={styles.cornerRadiusBar} aria-label="Corner rounding">
|
|
136
|
+
<span>Round</span>
|
|
137
|
+
<div className={styles.cornerRadiusSegments} role="radiogroup" aria-label="Corner radius">
|
|
138
|
+
{CORNER_RADIUS_STEPS.map((step) => (
|
|
139
|
+
<button
|
|
140
|
+
key={step.value}
|
|
141
|
+
type="button"
|
|
142
|
+
role="radio"
|
|
143
|
+
aria-checked={radius === step.value ? 'true' : 'false'}
|
|
144
|
+
className={cx(styles.cornerRadiusSegment, radius === step.value && styles.cornerRadiusSegmentOn)}
|
|
145
|
+
title={step.value === 0 ? 'Sharp (pixel) corners' : `Round corners, radius ${step.value}`}
|
|
146
|
+
onClick={() => onChange(step.value)}>
|
|
147
|
+
{step.label}
|
|
148
|
+
</button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
116
155
|
function ResolutionSelect({ label, value, onChange }) {
|
|
117
156
|
const options = RESOLUTION_STEPS.includes(value)
|
|
118
157
|
? RESOLUTION_STEPS
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
clampCornerRadius,
|
|
2
3
|
colorForKeyV2,
|
|
3
4
|
DEFAULT_DURATION_MS,
|
|
4
5
|
DEFAULT_RESOLUTION,
|
|
@@ -77,10 +78,19 @@ function blankSprite() {
|
|
|
77
78
|
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
78
79
|
tags: [],
|
|
79
80
|
defaultTag: undefined,
|
|
81
|
+
cornerRadius: 0,
|
|
80
82
|
layers: [makeLayer('layer-0', 'Layer 1', [null])],
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Set the file-level corner-rounding radius (0 = sharp/pixel). A no-op if
|
|
87
|
+
// it's already that value (keeps identical sprites from producing spurious
|
|
88
|
+
// history entries).
|
|
89
|
+
export function setCornerRadius(sprite, radius) {
|
|
90
|
+
const clamped = clampCornerRadius(radius);
|
|
91
|
+
return sprite.cornerRadius === clamped ? sprite : { ...sprite, cornerRadius: clamped };
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
function makeLayer(id, name, cells) {
|
|
85
95
|
return { id, name, visible: true, opacity: 1, blendMode: 'normal', kind: 'pixel', cells };
|
|
86
96
|
}
|
|
@@ -635,7 +645,10 @@ function shiftCellOffset(cell, dx, dy) {
|
|
|
635
645
|
// later needs no change here: if it survives the round-trip it was already
|
|
636
646
|
// representable; if not, it correctly forces the full form.
|
|
637
647
|
export function serializeModel(sprite) {
|
|
638
|
-
const compact = serializeCompact(
|
|
648
|
+
const compact = serializeCompact({
|
|
649
|
+
...fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)),
|
|
650
|
+
cornerRadius: sprite.cornerRadius,
|
|
651
|
+
});
|
|
639
652
|
const roundTripped = parseFull(compact);
|
|
640
653
|
if (roundTripped && losslesslyCompact(sprite, roundTripped)) return compact;
|
|
641
654
|
return serializeFull(sprite);
|
|
@@ -659,6 +672,7 @@ function losslesslyCompact(sprite, roundTripped) {
|
|
|
659
672
|
if (frameCount(sprite) !== frameCount(roundTripped)) return false;
|
|
660
673
|
if (sprite.tags.length !== roundTripped.tags.length) return false;
|
|
661
674
|
if ((sprite.defaultTag ?? null) !== (roundTripped.defaultTag ?? null)) return false;
|
|
675
|
+
if (sprite.cornerRadius !== roundTripped.cornerRadius) return false;
|
|
662
676
|
if (sprite.layers.length !== roundTripped.layers.length) return false;
|
|
663
677
|
|
|
664
678
|
const lookupA = paletteLookup(sprite.palette);
|
|
@@ -6,7 +6,7 @@ import { SceneUI } from './SceneUI';
|
|
|
6
6
|
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
7
7
|
// scenes are the place for that. The dev logs drawer is a builtin shell panel
|
|
8
8
|
// now (cli/src/shell), not rendered here.
|
|
9
|
-
export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
|
|
9
|
+
export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
|
|
10
10
|
const canvasRef = useRef(null);
|
|
11
11
|
const runtimeRef = useRef(null);
|
|
12
12
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
@@ -16,7 +16,7 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
|
|
|
16
16
|
const ctx = canvas.getContext('2d');
|
|
17
17
|
if (!ctx) return undefined;
|
|
18
18
|
configureSceneCanvas(canvas, ctx);
|
|
19
|
-
const runtime = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
19
|
+
const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
20
20
|
runtimeRef.current = runtime;
|
|
21
21
|
// Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
|
|
22
22
|
// logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Blueprint system: every actor in a scene is an instance of a blueprint (a
|
|
2
|
+
// `.scene` file under `blueprints/` whose `actors[0]` is the template, with no
|
|
3
|
+
// `id`). An instance stores only sparse per-property overrides in its own
|
|
4
|
+
// `components`; blueprint template values are the base, merged live from the
|
|
5
|
+
// current `files` map (never a load-time snapshot) so a blueprint edit in one
|
|
6
|
+
// panel propagates to every scene that places it. Shared by the runtime
|
|
7
|
+
// (`scene.js`, so play mode and `PlayOnly` resolve blueprints too) and the
|
|
8
|
+
// editors (which also mint/fork/migrate/cascade-delete blueprint files).
|
|
9
|
+
import { formatJson } from './files';
|
|
10
|
+
import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
|
|
11
|
+
import { cardSize, mintActorId } from './scene';
|
|
12
|
+
|
|
13
|
+
export const BLUEPRINTS_DIR = 'blueprints';
|
|
14
|
+
export const DRAWINGS_DIR = 'drawings';
|
|
15
|
+
|
|
16
|
+
export function isBlueprintPath(path) {
|
|
17
|
+
return typeof path === 'string' && path.startsWith(`${BLUEPRINTS_DIR}/`) && path.endsWith('.scene');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Per-property inherit metadata for a behavior prop. Behaviors opt a prop out
|
|
21
|
+
// of inheritance via `static propertyMeta = { propName: { inherit: false } }`
|
|
22
|
+
// (see behaviors/Layout.jsx for x/y/rotation); anything absent defaults to
|
|
23
|
+
// inherited. A non-inherited prop is always written as an instance value at
|
|
24
|
+
// placement time -- the blueprint's value for it is only a default for newly
|
|
25
|
+
// placed instances, and future blueprint edits never move an existing
|
|
26
|
+
// instance's copy of that prop.
|
|
27
|
+
export function getPropertyMeta(Behavior, prop) {
|
|
28
|
+
return Behavior?.propertyMeta?.[prop] ?? { inherit: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isInheritedProp(Behavior, prop) {
|
|
32
|
+
return getPropertyMeta(Behavior, prop).inherit !== false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse a blueprint file's text into { name, components } (the template
|
|
36
|
+
// actor's components), or null if the text isn't a valid blueprint (used to
|
|
37
|
+
// treat a tombstoned/deleted blueprint file as absent).
|
|
38
|
+
function parseBlueprintText(text) {
|
|
39
|
+
if (typeof text !== 'string') return null;
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const actor = data?.actors?.[0];
|
|
47
|
+
if (!actor || typeof actor !== 'object' || !actor.components) return null;
|
|
48
|
+
const name = typeof data.name === 'string' && data.name.trim() ? data.name : 'Blueprint';
|
|
49
|
+
return { name, components: actor.components };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve a blueprint's template from the LIVE files map -- the load-bearing
|
|
53
|
+
// call for live propagation. Returns null when the file is missing, deleted,
|
|
54
|
+
// or malformed (a dangling `blueprint` ref degrades to "no template" rather
|
|
55
|
+
// than throwing).
|
|
56
|
+
export function getBlueprintTemplate(files, blueprintPath) {
|
|
57
|
+
return parseBlueprintText(files?.[blueprintPath]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every blueprint currently in the deck, for the library/hotbar. Skips
|
|
61
|
+
// unparseable / tombstoned files (see `cascadeDeleteBlueprint`).
|
|
62
|
+
export function listBlueprints(files) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const path of Object.keys(files ?? {})) {
|
|
65
|
+
if (!isBlueprintPath(path)) continue;
|
|
66
|
+
const template = parseBlueprintText(files[path]);
|
|
67
|
+
if (!template) continue;
|
|
68
|
+
out.push({ path, name: template.name, components: template.components });
|
|
69
|
+
}
|
|
70
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Per-property merge: the blueprint template is the base, instance overrides
|
|
75
|
+
// win per-property within each component (not whole-component replacement). A
|
|
76
|
+
// component present on only one side appears whole.
|
|
77
|
+
export function mergeComponents(templateComponents, instanceComponents) {
|
|
78
|
+
const merged = {};
|
|
79
|
+
const names = new Set([
|
|
80
|
+
...Object.keys(templateComponents ?? {}),
|
|
81
|
+
...Object.keys(instanceComponents ?? {}),
|
|
82
|
+
]);
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const templateProps = templateComponents?.[name];
|
|
85
|
+
const instanceProps = instanceComponents?.[name];
|
|
86
|
+
if (templateProps === undefined) merged[name] = { ...instanceProps };
|
|
87
|
+
else if (instanceProps === undefined) merged[name] = { ...templateProps };
|
|
88
|
+
else merged[name] = { ...templateProps, ...instanceProps };
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The fully resolved components for one actor: its blueprint's template
|
|
94
|
+
// merged with its own sparse overrides, or its raw components when it carries
|
|
95
|
+
// no `blueprint` ref (the blueprint's own template actor, or a not-yet
|
|
96
|
+
// migrated legacy actor).
|
|
97
|
+
export function resolveActorComponents(files, actor) {
|
|
98
|
+
if (!actor?.blueprint) return actor?.components ?? {};
|
|
99
|
+
const template = getBlueprintTemplate(files, actor.blueprint);
|
|
100
|
+
return mergeComponents(template?.components, actor.components);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countMatches(text, regex) {
|
|
104
|
+
let max = 0;
|
|
105
|
+
const re = new RegExp(regex);
|
|
106
|
+
const m = re.exec(text ?? '');
|
|
107
|
+
return m ? Number(m[1]) : max;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// "Blueprint N" -- N is one more than the highest existing "Blueprint <n>"
|
|
111
|
+
// name (not just existing.length + 1, so a gap left by a deleted blueprint
|
|
112
|
+
// doesn't get reused and collide with intent elsewhere).
|
|
113
|
+
function mintBlueprintName(existingNames) {
|
|
114
|
+
let max = 0;
|
|
115
|
+
for (const name of existingNames) max = Math.max(max, countMatches(name, /^Blueprint (\d+)$/));
|
|
116
|
+
return `Blueprint ${max + 1}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `blueprints/blueprint-N.scene` -- the filename is a stable slug minted once
|
|
120
|
+
// at creation; nothing ever renames it afterward (renaming edits only `name`).
|
|
121
|
+
function mintBlueprintPath(existingPaths) {
|
|
122
|
+
let max = 0;
|
|
123
|
+
for (const path of existingPaths) {
|
|
124
|
+
max = Math.max(max, countMatches(path, new RegExp(`^${BLUEPRINTS_DIR}/blueprint-(\\d+)\\.scene$`)));
|
|
125
|
+
}
|
|
126
|
+
return `${BLUEPRINTS_DIR}/blueprint-${max + 1}.scene`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatBlueprintFileText(name, components) {
|
|
130
|
+
return formatJson({ name, actors: [{ components }] });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Rename a blueprint: replace its top-level `name`, preserving actors and any
|
|
134
|
+
// hand-added extras. Returns the next file text, or null when the file is
|
|
135
|
+
// missing/unparseable (caller should treat that as "blueprint gone" and skip
|
|
136
|
+
// the write). Empty/whitespace names are stored as-is; readers fall back to
|
|
137
|
+
// "Blueprint" for display (see getBlueprintTemplate).
|
|
138
|
+
export function setBlueprintName(files, blueprintPath, name) {
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(files?.[blueprintPath] ?? '');
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (!data || typeof data !== 'object') return null;
|
|
146
|
+
data.name = name;
|
|
147
|
+
return formatJson(data);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Replace a blueprint file's template components, preserving every other
|
|
151
|
+
// top-level field (name, hand-added extras). Returns the next file text, or
|
|
152
|
+
// null when the file is missing/unparseable/tombstoned (caller should treat
|
|
153
|
+
// that as "blueprint gone" and skip the write).
|
|
154
|
+
export function setBlueprintTemplateComponents(files, blueprintPath, components) {
|
|
155
|
+
let data;
|
|
156
|
+
try {
|
|
157
|
+
data = JSON.parse(files?.[blueprintPath] ?? '');
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (!data?.actors?.[0]) return null;
|
|
162
|
+
data.actors[0].components = components;
|
|
163
|
+
return formatJson(data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Mint a brand new blueprint file descriptor ({ path, name, text }) for the
|
|
167
|
+
// given template components. Does not write the file -- callers write it
|
|
168
|
+
// (via the SDK's `writeFile`) and, for the current scene, place an instance
|
|
169
|
+
// referencing `path`.
|
|
170
|
+
export function mintBlueprintFile(files, components) {
|
|
171
|
+
const existing = listBlueprints(files);
|
|
172
|
+
const name = mintBlueprintName(existing.map((blueprint) => blueprint.name));
|
|
173
|
+
const path = mintBlueprintPath(existing.map((blueprint) => blueprint.path));
|
|
174
|
+
return { path, name, text: formatBlueprintFileText(name, components) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Split a resolved component's props into { inherited, overridden } by each
|
|
178
|
+
// prop's propertyMeta. Used by migration and fork to decide what belongs in a
|
|
179
|
+
// new blueprint template vs. what must stay an explicit instance value.
|
|
180
|
+
function splitByInherit(Behavior, props) {
|
|
181
|
+
const inherited = {};
|
|
182
|
+
const overridden = {};
|
|
183
|
+
for (const [prop, value] of Object.entries(props ?? {})) {
|
|
184
|
+
if (isInheritedProp(Behavior, prop)) inherited[prop] = value;
|
|
185
|
+
else overridden[prop] = value;
|
|
186
|
+
}
|
|
187
|
+
return { inherited, overridden };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findBehavior(behaviors, name) {
|
|
191
|
+
return behaviors.find((candidate) => candidate.behaviorName === name);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Apply an inspector-style partial patch (`{ propName: value, ... }`) to one
|
|
195
|
+
// actor's component, IN PLACE. For a blueprint's own template actor (no
|
|
196
|
+
// `blueprint` ref) this is a plain merge -- there's no baseline to diff
|
|
197
|
+
// against. For an instance, each patched prop is diffed against the
|
|
198
|
+
// blueprint's current value (falling back to the behavior's defaultProps):
|
|
199
|
+
// an inherited prop that becomes equal to the blueprint again drops its
|
|
200
|
+
// override (so it goes back to tracking blueprint edits); anything else is
|
|
201
|
+
// recorded as an override. Never writes a full component copy.
|
|
202
|
+
export function applyComponentPatch(files, behaviors, actor, behaviorName, nextProps) {
|
|
203
|
+
actor.components ??= {};
|
|
204
|
+
if (!actor.blueprint) {
|
|
205
|
+
actor.components[behaviorName] = { ...(actor.components[behaviorName] ?? {}), ...nextProps };
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const template = getBlueprintTemplate(files, actor.blueprint);
|
|
209
|
+
const templateProps = template?.components?.[behaviorName] ?? {};
|
|
210
|
+
const Behavior = findBehavior(behaviors, behaviorName);
|
|
211
|
+
const defaultProps = Behavior?.defaultProps ?? {};
|
|
212
|
+
const overrides = { ...(actor.components[behaviorName] ?? {}) };
|
|
213
|
+
for (const [prop, value] of Object.entries(nextProps)) {
|
|
214
|
+
const baseline = prop in templateProps ? templateProps[prop] : defaultProps[prop];
|
|
215
|
+
if (isInheritedProp(Behavior, prop) && Object.is(value, baseline)) delete overrides[prop];
|
|
216
|
+
else overrides[prop] = value;
|
|
217
|
+
}
|
|
218
|
+
if (Object.keys(overrides).length === 0) delete actor.components[behaviorName];
|
|
219
|
+
else actor.components[behaviorName] = overrides;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Single-actor convenience wrapper around `applyComponentPatch` for the
|
|
223
|
+
// inspector: clones the scene, patches the one actor's component, returns the
|
|
224
|
+
// next scene (or the original when the actor is missing).
|
|
225
|
+
export function setInstanceComponent(files, behaviors, sceneData, actorId, behaviorName, nextProps) {
|
|
226
|
+
const next = structuredClone(sceneData);
|
|
227
|
+
const actor = next.actors?.find((candidate) => candidate.id === actorId);
|
|
228
|
+
if (!actor) return sceneData;
|
|
229
|
+
applyComponentPatch(files, behaviors, actor, behaviorName, nextProps);
|
|
230
|
+
return next;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function snapValue(value, snap) {
|
|
234
|
+
if (!snap?.enabled) return Math.round(value);
|
|
235
|
+
return Math.round(value / snap.gridSize) * snap.gridSize;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// "Add actor" = mint a new blueprint AND place its first instance in one
|
|
239
|
+
// gesture. Returns the next scene, the new instance id, and the new
|
|
240
|
+
// blueprint's { path, name, text } for the caller to write to disk.
|
|
241
|
+
// A blank 16x16 (DEFAULT_RESOLUTION) compact `.pxart`: all-transparent grid,
|
|
242
|
+
// empty palette. This is what a brand-new blueprint's sprite points at, so the
|
|
243
|
+
// "add actor" button always yields a fresh empty canvas to draw into rather
|
|
244
|
+
// than reusing whatever art happened to be first in drawings/.
|
|
245
|
+
export function blankPxArtText() {
|
|
246
|
+
const { width, height } = DEFAULT_RESOLUTION;
|
|
247
|
+
const grid = Array.from({ length: height }, () => TRANSPARENT.repeat(width));
|
|
248
|
+
return serializeCompact({ palette: {}, grid });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `drawings/drawing-N.pxart` -- next free numbered slug, so repeated adds don't
|
|
252
|
+
// collide and a deleted drawing's number isn't reused.
|
|
253
|
+
function mintDrawingPath(files) {
|
|
254
|
+
const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.pxart$`);
|
|
255
|
+
let max = 0;
|
|
256
|
+
for (const path of Object.keys(files ?? {})) {
|
|
257
|
+
const m = path.match(re);
|
|
258
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
259
|
+
}
|
|
260
|
+
return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function addActorWithBlueprint(sceneData, files) {
|
|
264
|
+
const width = 64;
|
|
265
|
+
const height = 64;
|
|
266
|
+
const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
|
|
267
|
+
const components = {
|
|
268
|
+
Layout: { width, height, z: 0 },
|
|
269
|
+
Sprite: { file: drawingFile.path },
|
|
270
|
+
};
|
|
271
|
+
const blueprintFile = mintBlueprintFile(files, components);
|
|
272
|
+
const next = structuredClone(sceneData);
|
|
273
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
274
|
+
const newId = mintActorId(existingIds);
|
|
275
|
+
const x = Math.round((cardSize.width - width) / 2);
|
|
276
|
+
const y = Math.round((cardSize.height - height) / 2);
|
|
277
|
+
next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y } } });
|
|
278
|
+
return { sceneData: next, newId, blueprintFile, drawingFile };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Centered, grid-snapped Layout x/y for a blueprint's template at a
|
|
282
|
+
// card-space point. Shared by initial drag-in placement and the subsequent
|
|
283
|
+
// moves while the drag is still in progress, so both snap identically.
|
|
284
|
+
export function blueprintDropXY(files, blueprintPath, position, snap) {
|
|
285
|
+
const template = getBlueprintTemplate(files, blueprintPath);
|
|
286
|
+
const layout = template?.components?.Layout ?? {};
|
|
287
|
+
const width = layout.width ?? 64;
|
|
288
|
+
const height = layout.height ?? 64;
|
|
289
|
+
const dropX = position?.x ?? cardSize.width / 2;
|
|
290
|
+
const dropY = position?.y ?? cardSize.height / 2;
|
|
291
|
+
return {
|
|
292
|
+
x: snapValue(dropX - width / 2, snap),
|
|
293
|
+
y: snapValue(dropY - height / 2, snap),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Place a new instance of an existing blueprint at a drop point (card units,
|
|
298
|
+
// pre-snap), grid-snapped and centered on the point using the template's
|
|
299
|
+
// Layout size. Only Layout x/y are written on the instance -- everything else
|
|
300
|
+
// inherits from the blueprint.
|
|
301
|
+
export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
|
|
302
|
+
const next = structuredClone(sceneData);
|
|
303
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
304
|
+
const newId = mintActorId(existingIds);
|
|
305
|
+
const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
|
|
306
|
+
next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
|
|
307
|
+
return { sceneData: next, newId };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// "New blueprint from this actor": mint a blueprint whose template is the
|
|
311
|
+
// instance's fully merged components, with non-inherited props reset to their
|
|
312
|
+
// behavior defaults in the TEMPLATE (a fresh blueprint has no instances yet to
|
|
313
|
+
// carry position, so its own template position is a neutral default).
|
|
314
|
+
// Reparent the instance to the new blueprint, keeping only its non-inherited
|
|
315
|
+
// prop values (Layout position et al) as instance overrides -- everything
|
|
316
|
+
// else now matches the new template exactly, so no override is needed for it.
|
|
317
|
+
export function forkActorToBlueprint(files, behaviors, sceneData, actorId) {
|
|
318
|
+
const next = structuredClone(sceneData);
|
|
319
|
+
const actor = next.actors?.find((candidate) => candidate.id === actorId);
|
|
320
|
+
if (!actor) return null;
|
|
321
|
+
const merged = resolveActorComponents(files, actor);
|
|
322
|
+
const templateComponents = {};
|
|
323
|
+
const instanceOverrides = {};
|
|
324
|
+
for (const [behaviorName, props] of Object.entries(merged)) {
|
|
325
|
+
if (!props) continue;
|
|
326
|
+
const Behavior = findBehavior(behaviors, behaviorName);
|
|
327
|
+
const { inherited, overridden } = splitByInherit(Behavior, props);
|
|
328
|
+
if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
|
|
329
|
+
// The new template's non-inherited props reset to the behavior's default
|
|
330
|
+
// (a fresh blueprint has no instances yet to carry a real position); its
|
|
331
|
+
// inherited props keep the actor's current (merged) values verbatim.
|
|
332
|
+
const defaultProps = Behavior?.defaultProps ?? {};
|
|
333
|
+
const resetOverridden = Object.fromEntries(
|
|
334
|
+
Object.keys(overridden).map((prop) => [prop, prop in defaultProps ? defaultProps[prop] : overridden[prop]])
|
|
335
|
+
);
|
|
336
|
+
if (Object.keys(inherited).length > 0 || Object.keys(resetOverridden).length > 0) {
|
|
337
|
+
templateComponents[behaviorName] = { ...inherited, ...resetOverridden };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const blueprintFile = mintBlueprintFile(files, templateComponents);
|
|
341
|
+
actor.blueprint = blueprintFile.path;
|
|
342
|
+
actor.components = instanceOverrides;
|
|
343
|
+
return { sceneData: next, blueprintFile };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Every `scenes/*.scene` file's parsed data, for callers that need to scan
|
|
347
|
+
// every scene in the deck (cascade delete, the instance-count badge/confirm).
|
|
348
|
+
// Skips unparseable files rather than throwing -- a mid-edit scene with
|
|
349
|
+
// invalid JSON just doesn't count towards these scans.
|
|
350
|
+
function parseSceneFiles(files) {
|
|
351
|
+
const out = [];
|
|
352
|
+
for (const [path, text] of Object.entries(files ?? {})) {
|
|
353
|
+
if (!path.startsWith('scenes/') || !path.endsWith('.scene')) continue;
|
|
354
|
+
try {
|
|
355
|
+
out.push({ path, data: JSON.parse(text) });
|
|
356
|
+
} catch {
|
|
357
|
+
// skip
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Every `scenes/*.scene` file's actor count referencing a blueprint, for the
|
|
364
|
+
// delete-confirm dialog and the library's per-blueprint badge.
|
|
365
|
+
export function countBlueprintInstances(files, blueprintPath) {
|
|
366
|
+
let count = 0;
|
|
367
|
+
for (const { data } of parseSceneFiles(files)) {
|
|
368
|
+
for (const actor of data?.actors ?? []) {
|
|
369
|
+
if (actor?.blueprint === blueprintPath) count += 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return count;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Cascade-delete a blueprint: every instance in every scene is removed, and
|
|
376
|
+
// the blueprint file itself is tombstoned (overwritten with an empty scene --
|
|
377
|
+
// `listBlueprints` skips it and it stops resolving). Returns the list of
|
|
378
|
+
// { path, text } writes for the caller to apply (the currently open scene
|
|
379
|
+
// should go through that editor's own history/onChange so undo still works
|
|
380
|
+
// for it; every other file is a direct `writeFile`).
|
|
381
|
+
export function cascadeDeleteBlueprint(files, blueprintPath) {
|
|
382
|
+
const writes = [];
|
|
383
|
+
for (const { path, data } of parseSceneFiles(files)) {
|
|
384
|
+
const actors = data.actors ?? [];
|
|
385
|
+
const filtered = actors.filter((actor) => actor?.blueprint !== blueprintPath);
|
|
386
|
+
if (filtered.length !== actors.length) {
|
|
387
|
+
writes.push({ path, text: formatJson({ ...data, actors: filtered }) });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
writes.push({ path: blueprintPath, text: formatJson({}) });
|
|
391
|
+
return writes;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Auto-migrate a scene's orphan actors (no `blueprint` field -- the pre-v1
|
|
395
|
+
// shape) into blueprint instances: mint one blueprint per orphan (its
|
|
396
|
+
// authored components split by propertyMeta, same as fork), rewrite the actor
|
|
397
|
+
// to reference it with only the non-inherited props as overrides. Mechanical
|
|
398
|
+
// and safe: never touches an actor that already has a `blueprint`.
|
|
399
|
+
export function migrateOrphanActors(files, behaviors, sceneData) {
|
|
400
|
+
const next = structuredClone(sceneData);
|
|
401
|
+
const newBlueprintFiles = [];
|
|
402
|
+
let knownBlueprints = listBlueprints(files);
|
|
403
|
+
for (const actor of next.actors ?? []) {
|
|
404
|
+
if (actor.blueprint) continue;
|
|
405
|
+
const templateComponents = {};
|
|
406
|
+
const instanceOverrides = {};
|
|
407
|
+
for (const [behaviorName, props] of Object.entries(actor.components ?? {})) {
|
|
408
|
+
if (!props) continue;
|
|
409
|
+
const Behavior = findBehavior(behaviors, behaviorName);
|
|
410
|
+
const { inherited, overridden } = splitByInherit(Behavior, props);
|
|
411
|
+
if (Object.keys(inherited).length > 0) templateComponents[behaviorName] = inherited;
|
|
412
|
+
if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
|
|
413
|
+
}
|
|
414
|
+
const name = mintBlueprintName(knownBlueprints.map((blueprint) => blueprint.name));
|
|
415
|
+
const path = mintBlueprintPath(knownBlueprints.map((blueprint) => blueprint.path));
|
|
416
|
+
const text = formatBlueprintFileText(name, templateComponents);
|
|
417
|
+
newBlueprintFiles.push({ path, text });
|
|
418
|
+
knownBlueprints = [...knownBlueprints, { path, name, components: templateComponents }];
|
|
419
|
+
actor.blueprint = path;
|
|
420
|
+
actor.components = instanceOverrides;
|
|
421
|
+
}
|
|
422
|
+
return { sceneData: next, newBlueprintFiles };
|
|
423
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Seed the file map by scanning the deck dir. Vite module-cache is invalidated
|
|
2
2
|
// on restart, so a newly-created file just shows up on the next reload.
|
|
3
3
|
const rawModules = import.meta.glob(
|
|
4
|
-
['../scenes/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
|
|
4
|
+
['../scenes/*.scene', '../blueprints/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
|
|
5
5
|
{ query: '?raw', import: 'default', eager: true }
|
|
6
6
|
);
|
|
7
7
|
export const initialFiles = Object.fromEntries(
|
|
@@ -40,6 +40,46 @@ export const COMPACT_FORM = 'compact';
|
|
|
40
40
|
export const FULL_FORM = 'full';
|
|
41
41
|
export const TRANSPARENT = '.';
|
|
42
42
|
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// cornerRadius (file-level corner-rounding radius, in native-pixel units)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
//
|
|
47
|
+
// `cornerRadius` is a plain number: 0 renders sharp/nearest-neighbor (the
|
|
48
|
+
// historical/default look, 1px/cell); anything > 0 is a corner-rounding
|
|
49
|
+
// radius passed straight through to `pxartSmooth.js`'s local corner kernel.
|
|
50
|
+
// A VALUE rather than a "pixel" | "smooth" flag, so the amount of rounding is
|
|
51
|
+
// itself part of the portable file format instead of a fixed, code-side
|
|
52
|
+
// constant every smooth sprite is stuck with.
|
|
53
|
+
|
|
54
|
+
/** Corner cuts on the same 1-native-pixel edge must not overlap, so radii
|
|
55
|
+
* above this are clamped on parse (and by the editor's UI). */
|
|
56
|
+
export const MAX_CORNER_RADIUS = 0.5;
|
|
57
|
+
|
|
58
|
+
export function clampCornerRadius(radius) {
|
|
59
|
+
return Math.min(MAX_CORNER_RADIUS, Math.max(0, radius));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// This field used to be called `render`: first a "pixel" | "smooth" string
|
|
63
|
+
// enum, then (briefly) a bare numeric radius under that same key. Both
|
|
64
|
+
// migrate on read so files from either era keep rendering rounded rather
|
|
65
|
+
// than silently reverting to sharp; "smooth" specifically migrates to this
|
|
66
|
+
// fixed value, since the string enum never carried an amount of its own.
|
|
67
|
+
const LEGACY_SMOOTH_RADIUS = 0.25;
|
|
68
|
+
|
|
69
|
+
/** Read the corner radius off a parsed JSON object, preferring the current
|
|
70
|
+
* `cornerRadius` key and falling back to the legacy `render` key (see
|
|
71
|
+
* above). Defaults to 0 (sharp) for anything else (missing field, typo, a
|
|
72
|
+
* future value this parser doesn't know yet). */
|
|
73
|
+
function parseCornerRadius(data) {
|
|
74
|
+
if (typeof data.cornerRadius === 'number' && Number.isFinite(data.cornerRadius)) {
|
|
75
|
+
return clampCornerRadius(data.cornerRadius);
|
|
76
|
+
}
|
|
77
|
+
const legacy = data.render;
|
|
78
|
+
if (typeof legacy === 'number' && Number.isFinite(legacy)) return clampCornerRadius(legacy);
|
|
79
|
+
if (legacy === 'smooth') return LEGACY_SMOOTH_RADIUS;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
43
83
|
/** Parse a .pxart string into a compact PxArt record, or null if it isn't the
|
|
44
84
|
* compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
|
|
45
85
|
* `format` discriminator is optional. Tolerant of ragged rows and a `"."`
|
|
@@ -61,7 +101,7 @@ export function parseCompact(content) {
|
|
|
61
101
|
if (typeof v === 'string') palette[k] = v;
|
|
62
102
|
else if (v === null) palette[k] = null;
|
|
63
103
|
}
|
|
64
|
-
return { palette, grid: data.grid };
|
|
104
|
+
return { palette, grid: data.grid, cornerRadius: parseCornerRadius(data) };
|
|
65
105
|
}
|
|
66
106
|
} catch {
|
|
67
107
|
/* not valid pxart json */
|
|
@@ -70,12 +110,14 @@ export function parseCompact(content) {
|
|
|
70
110
|
}
|
|
71
111
|
|
|
72
112
|
/** Serialize a compact PxArt record to a .pxart string. Always stamps
|
|
73
|
-
* `format: "compact"`.
|
|
113
|
+
* `format: "compact"`. `cornerRadius` is omitted when it's 0 (sharp), so
|
|
114
|
+
* existing (pixel) files stay byte-identical. */
|
|
74
115
|
export function serializeCompact(art) {
|
|
75
116
|
const out = {
|
|
76
117
|
format: COMPACT_FORM,
|
|
77
118
|
palette: art.palette,
|
|
78
119
|
grid: art.grid,
|
|
120
|
+
...(art.cornerRadius > 0 ? { cornerRadius: art.cornerRadius } : {}),
|
|
79
121
|
};
|
|
80
122
|
return JSON.stringify(out, null, 2) + '\n';
|
|
81
123
|
}
|
|
@@ -548,6 +590,7 @@ export function parseFull(content) {
|
|
|
548
590
|
defaultDurationMs,
|
|
549
591
|
tags,
|
|
550
592
|
defaultTag,
|
|
593
|
+
cornerRadius: parseCornerRadius(data),
|
|
551
594
|
layers,
|
|
552
595
|
};
|
|
553
596
|
}
|
|
@@ -581,6 +624,7 @@ export function upgradeCompactToFull(art) {
|
|
|
581
624
|
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
582
625
|
tags: [],
|
|
583
626
|
defaultTag: undefined,
|
|
627
|
+
cornerRadius: art.cornerRadius,
|
|
584
628
|
layers: [
|
|
585
629
|
{
|
|
586
630
|
id: 'layer-0',
|
|
@@ -641,6 +685,9 @@ export function serializeFull(sprite) {
|
|
|
641
685
|
repeat: t.repeat,
|
|
642
686
|
})),
|
|
643
687
|
...(sprite.defaultTag !== undefined ? { defaultTag: sprite.defaultTag } : {}),
|
|
688
|
+
// Omitted when it's 0 (sharp), so pixel-mode files stay byte-identical to
|
|
689
|
+
// their pre-smoothing shape.
|
|
690
|
+
...(sprite.cornerRadius > 0 ? { cornerRadius: sprite.cornerRadius } : {}),
|
|
644
691
|
layers: sprite.layers.map((l) => ({
|
|
645
692
|
id: l.id,
|
|
646
693
|
name: l.name,
|