castle-web-cli 0.4.76 → 0.4.78
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 +9 -0
- package/dist/agent-prompts.js +27 -9
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +718 -244
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +7 -1
- package/dist/filesChanged.d.ts +25 -0
- package/dist/filesChanged.js +140 -0
- package/dist/ide.js +2 -0
- package/dist/init.js +1 -1
- package/dist/serve.js +18 -2
- package/dist/shell/assets/{index-DuKq-Grp.css → index-WE24qX3d.css} +1 -1
- package/dist/shell/assets/{index-DNWEQd4R.js → index-yGdKhgfZ.js} +22 -22
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +15 -3
- package/kits/basic-2d/docs/pxart-format.md +79 -1
- package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +30 -12
- package/kits/basic-2d/editors/SingleEditor.jsx +24 -8
- package/kits/basic-2d/editors/editorHistory.js +87 -15
- 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/liveReload.js +88 -0
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/ui.module.css +108 -14
- package/package.json +1 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
|
@@ -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);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Consumer side of the serve's `files_changed` broadcast. The engine decides
|
|
2
|
+
// what a change means for this bundle:
|
|
3
|
+
// - data files (.scene / .pxart / .drawing) are re-read over the serve's
|
|
4
|
+
// files API and folded into React state — live re-render, no reload.
|
|
5
|
+
// - code the bundle imports (per the event's module-graph closure), html,
|
|
6
|
+
// or added/deleted code files (import.meta.glob may pick them up) mean
|
|
7
|
+
// this context is stale — requestReload() (save-state hooks run first).
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { onFilesChanged, requestReload } from 'castle-web-sdk';
|
|
10
|
+
import { initialFiles } from './files';
|
|
11
|
+
|
|
12
|
+
const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
|
|
13
|
+
const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
|
|
14
|
+
|
|
15
|
+
export function isDataFile(path) {
|
|
16
|
+
return DATA_EXTS.some((ext) => path.endsWith(ext));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function needsContextReload(change) {
|
|
20
|
+
if (isDataFile(change.path)) return false;
|
|
21
|
+
if (change.path.endsWith('.html')) return true;
|
|
22
|
+
// In the module graph (directly or as someone's import) -> our code changed.
|
|
23
|
+
if (change.affected.length > 0) return true;
|
|
24
|
+
// A new/removed code file isn't in the graph yet but a glob may scan it.
|
|
25
|
+
if (change.event !== 'change') return CODE_EXTS.some((ext) => change.path.endsWith(ext));
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readDeckFile(path) {
|
|
30
|
+
const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(path)}`);
|
|
31
|
+
if (!res.ok) throw new Error(`read failed: ${res.status}`);
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
return typeof data.contents === 'string' ? data.contents : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Deck files as live state: starts from the build-time glob snapshot and stays
|
|
37
|
+
// current as files change on disk from any source (editors, agents, terminal).
|
|
38
|
+
// `shouldSkipPath` lets an editor keep its own in-flight edits from being
|
|
39
|
+
// clobbered by the fs echo of a stale write. `dataVersion` bumps on every
|
|
40
|
+
// applied data change — key a player on it to rebuild from fresh data.
|
|
41
|
+
export function useLiveDeckFiles({ shouldSkipPath } = {}) {
|
|
42
|
+
const [files, setFiles] = useState(initialFiles);
|
|
43
|
+
const [dataVersion, setDataVersion] = useState(0);
|
|
44
|
+
const skipRef = useRef(shouldSkipPath);
|
|
45
|
+
skipRef.current = shouldSkipPath;
|
|
46
|
+
|
|
47
|
+
useEffect(
|
|
48
|
+
() =>
|
|
49
|
+
onFilesChanged((event) => {
|
|
50
|
+
if (event.changes.some(needsContextReload)) {
|
|
51
|
+
requestReload();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const dataChanges = event.changes.filter(
|
|
55
|
+
(change) => isDataFile(change.path) && !skipRef.current?.(change.path)
|
|
56
|
+
);
|
|
57
|
+
if (dataChanges.length === 0) return;
|
|
58
|
+
void applyDataChanges(dataChanges, setFiles, setDataVersion);
|
|
59
|
+
}),
|
|
60
|
+
[]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { files, setFiles, dataVersion };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function applyDataChanges(changes, setFiles, setDataVersion) {
|
|
67
|
+
const updates = await Promise.all(
|
|
68
|
+
changes.map(async (change) => {
|
|
69
|
+
if (change.event === 'delete') return { path: change.path, text: null };
|
|
70
|
+
try {
|
|
71
|
+
return { path: change.path, text: await readDeckFile(change.path) };
|
|
72
|
+
} catch {
|
|
73
|
+
return null; // read raced a delete / serve hiccup — skip this path
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
const applied = updates.filter(Boolean);
|
|
78
|
+
if (applied.length === 0) return;
|
|
79
|
+
setFiles((current) => {
|
|
80
|
+
const next = { ...current };
|
|
81
|
+
for (const { path, text } of applied) {
|
|
82
|
+
if (text === null) delete next[path];
|
|
83
|
+
else next[path] = text;
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
setDataVersion((version) => version + 1);
|
|
88
|
+
}
|
|
@@ -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,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// "Smooth" (Animal-Crossing-style) rendering for `.pxart` sprites.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Sprites with a file-level `cornerRadius` > 0 (see pxart.js /
|
|
6
|
+
// docs/pxart-format.md) render through `renderSmoothSpriteFrame` instead of
|
|
7
|
+
// the normal 1px/cell `renderSpriteFrame`.
|
|
8
|
+
//
|
|
9
|
+
// This is a LOCAL, per-pixel kernel filter — the same shape of algorithm as
|
|
10
|
+
// Animal Crossing's actual smoothing (xBRZ-style template matching), not the
|
|
11
|
+
// global boundary-trace-and-round approach this file used to implement. That
|
|
12
|
+
// approach traced each region's FULL pixel-boundary loop and could detect
|
|
13
|
+
// long straight/staircase runs across the whole loop; it looked fine on
|
|
14
|
+
// blocky shapes but flattened organic curves it decided were "too regular",
|
|
15
|
+
// which isn't fixable by tuning since the run detection itself is the
|
|
16
|
+
// problem. This version never looks past a pixel's immediate 3x3
|
|
17
|
+
// neighborhood, so there is no run/regularity detection to mis-fire — it
|
|
18
|
+
// physically cannot flatten a curve, because it has no notion of a curve at
|
|
19
|
+
// all, only of each corner in isolation.
|
|
20
|
+
//
|
|
21
|
+
// Approach, per source pixel P:
|
|
22
|
+
// 1. Composite the frame normally (`renderSpriteFrame` — this already
|
|
23
|
+
// handles layer visibility/opacity/blend), then read back the native
|
|
24
|
+
// resolution raster. Regions are exact-RGBA-equality color runs, same as
|
|
25
|
+
// before.
|
|
26
|
+
// 2. Supersample: P gets its own `scale` x `scale` block of the output
|
|
27
|
+
// canvas (plain grid subdivision — every output pixel belongs to
|
|
28
|
+
// exactly one source pixel's block, so there is no possibility of a
|
|
29
|
+
// gap or overlap between neighboring pixels' rendering).
|
|
30
|
+
// 3. Fill P's whole block with its own color, then independently classify
|
|
31
|
+
// each of P's 4 corners against ONLY the 3 pixels touching that corner
|
|
32
|
+
// (2 edge-adjacent neighbors + 1 diagonal) and, for genuine convex
|
|
33
|
+
// corners, paint a `cornerRadius`-sized quarter-circle "cut" over that
|
|
34
|
+
// corner revealing the relevant neighbor's color (see `cornerFill`).
|
|
35
|
+
// Because every pixel only ever paints within its OWN block, this recoloring
|
|
36
|
+
// can never create a gap: it's the same guarantee a supersampled nearest-
|
|
37
|
+
// neighbor render already has, just with a softened corner instead of a hard
|
|
38
|
+
// one.
|
|
39
|
+
//
|
|
40
|
+
// Kept as a sibling of pxart.js (rather than inside it) so the format
|
|
41
|
+
// parser/serializer stays focused on the on-disk shape.
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
import { MAX_CORNER_RADIUS, renderSpriteFrame } from './pxart';
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_SMOOTH_SCALE = 8;
|
|
47
|
+
|
|
48
|
+
// Fallback only for a caller that omits `cornerRadius` entirely; every real
|
|
49
|
+
// caller passes the sprite's own file-level `cornerRadius` value (see
|
|
50
|
+
// docs/pxart-format.md and Sprite.jsx/PxArtEditor.jsx), which is how the
|
|
51
|
+
// corner radius ends up being a portable, per-sprite part of the format
|
|
52
|
+
// rather than a fixed constant every smooth sprite is stuck with.
|
|
53
|
+
const FALLBACK_CORNER_RADIUS = 0.25;
|
|
54
|
+
|
|
55
|
+
/** Render one frame of a Sprite with locally corner-rounded fills,
|
|
56
|
+
* supersampled into `canvas` (sized to width*scale x height*scale). Reuses
|
|
57
|
+
* `renderSpriteFrame` for compositing (layers, opacity, visibility, blend),
|
|
58
|
+
* so those keep working unchanged. `cornerRadius` is in native-pixel units,
|
|
59
|
+
* clamped to `MAX_CORNER_RADIUS` (two cuts on the same edge must not
|
|
60
|
+
* overlap). */
|
|
61
|
+
export function renderSmoothSpriteFrame(
|
|
62
|
+
sprite,
|
|
63
|
+
frameIndex,
|
|
64
|
+
canvas,
|
|
65
|
+
{ scale = DEFAULT_SMOOTH_SCALE, cornerRadius = FALLBACK_CORNER_RADIUS } = {}
|
|
66
|
+
) {
|
|
67
|
+
const clampedRadius = Math.min(MAX_CORNER_RADIUS, Math.max(0, cornerRadius));
|
|
68
|
+
const { width, height } = sprite.resolution;
|
|
69
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
70
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
71
|
+
const ctx = canvas.getContext('2d');
|
|
72
|
+
if (!ctx) return;
|
|
73
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
74
|
+
if (width <= 0 || height <= 0) return;
|
|
75
|
+
|
|
76
|
+
const native = document.createElement('canvas');
|
|
77
|
+
renderSpriteFrame(sprite, frameIndex, native);
|
|
78
|
+
const nctx = native.getContext('2d');
|
|
79
|
+
const data = nctx?.getImageData(0, 0, width, height).data;
|
|
80
|
+
if (!data) return;
|
|
81
|
+
|
|
82
|
+
// null = out of canvas OR fully transparent; both read as "not this pixel's
|
|
83
|
+
// color" to every classification below, same as the old mask's treatment
|
|
84
|
+
// of out-of-canvas/transparent as background.
|
|
85
|
+
const colorAt = (x, y) => {
|
|
86
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return null;
|
|
87
|
+
const i = (y * width + x) * 4;
|
|
88
|
+
const a = data[i + 3];
|
|
89
|
+
return a === 0 ? null : `${data[i]},${data[i + 1]},${data[i + 2]},${a}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ctx.save();
|
|
93
|
+
ctx.scale(scale, scale);
|
|
94
|
+
// Every pixel is processed, including transparent ones: a fully-enclosed
|
|
95
|
+
// transparent "hole" is, from its own corners' point of view, exactly the
|
|
96
|
+
// same kind of convex corner as an opaque pixel poking out of a
|
|
97
|
+
// background — it needs to cut (and round) its OWN corners the same way,
|
|
98
|
+
// or an enclosed 1px hole would stay a hard square forever (its opaque
|
|
99
|
+
// neighbors never round toward it, by the same-neighbor rule below).
|
|
100
|
+
for (let y = 0; y < height; y++) {
|
|
101
|
+
for (let x = 0; x < width; x++) drawPixelBlock(ctx, colorAt, x, y, colorAt(x, y), clampedRadius);
|
|
102
|
+
}
|
|
103
|
+
ctx.restore();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// per-pixel local corner kernel
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const NO_CUT = undefined;
|
|
111
|
+
|
|
112
|
+
// Fill pixel (x, y)'s own unit square [x, x+1] x [y, y+1] with `color` (a
|
|
113
|
+
// no-op when `color` is null — transparent, nothing to fill), then paint a
|
|
114
|
+
// small rounded cut over each of its 4 corners that qualifies (see
|
|
115
|
+
// `cornerFill`). Uses ONLY the 3x3 neighborhood of (x, y) — genuinely local,
|
|
116
|
+
// unlike the old global loop trace.
|
|
117
|
+
function drawPixelBlock(ctx, colorAt, x, y, color, cornerRadius) {
|
|
118
|
+
const north = colorAt(x, y - 1);
|
|
119
|
+
const south = colorAt(x, y + 1);
|
|
120
|
+
const west = colorAt(x - 1, y);
|
|
121
|
+
const east = colorAt(x + 1, y);
|
|
122
|
+
|
|
123
|
+
const cuts = [
|
|
124
|
+
['TL', cornerFill(color, west, north, colorAt(x - 1, y - 1))],
|
|
125
|
+
['TR', cornerFill(color, east, north, colorAt(x + 1, y - 1))],
|
|
126
|
+
['BL', cornerFill(color, west, south, colorAt(x - 1, y + 1))],
|
|
127
|
+
['BR', cornerFill(color, east, south, colorAt(x + 1, y + 1))],
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
if (color) {
|
|
131
|
+
ctx.fillStyle = rgbaFillStyle(color);
|
|
132
|
+
ctx.fillRect(x, y, 1, 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (cornerRadius <= 0) return;
|
|
136
|
+
for (const [corner, reveal] of cuts) {
|
|
137
|
+
if (reveal === NO_CUT) continue;
|
|
138
|
+
ctx.beginPath();
|
|
139
|
+
tracePixelWedge(ctx, corner, x, y, cornerRadius);
|
|
140
|
+
if (reveal === null) {
|
|
141
|
+
// Revealing transparency: clip to the wedge and clear it, rather than
|
|
142
|
+
// fill it, since there's no color to paint.
|
|
143
|
+
ctx.save();
|
|
144
|
+
ctx.clip();
|
|
145
|
+
ctx.clearRect(x, y, 1, 1);
|
|
146
|
+
ctx.restore();
|
|
147
|
+
} else {
|
|
148
|
+
ctx.fillStyle = rgbaFillStyle(reveal);
|
|
149
|
+
ctx.fill();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Classify one corner of pixel `color`, given its two edge-adjacent
|
|
155
|
+
// neighbors (`a`, `b`) and its diagonal neighbor (`g`). Returns the neighbor
|
|
156
|
+
// color to reveal at that corner's rounded cut, or `NO_CUT` to leave the
|
|
157
|
+
// corner sharp:
|
|
158
|
+
// - `a` or `b` matches `color`: either a flat/interior corner, or (when
|
|
159
|
+
// they don't BOTH match) a straight edge passing by rather than a real
|
|
160
|
+
// corner. Either way, nothing to round from P's side — a matching
|
|
161
|
+
// neighbor's OWN corner classification (evaluated independently, when
|
|
162
|
+
// IT is P) is what rounds the opposite case; the physical wedge that
|
|
163
|
+
// gets cut always lives entirely inside whichever pixel's corner is
|
|
164
|
+
// convex, so there's no double-handling.
|
|
165
|
+
// - neither `a` nor `b` matches, but `g` DOES: a diagonal touch between
|
|
166
|
+
// two same-colored pixels (the "checkerboard" case). Left uncut, so a
|
|
167
|
+
// smoothed diagonal stroke doesn't get visually pinched off at every
|
|
168
|
+
// step.
|
|
169
|
+
// - neither `a`, `b`, nor `g` matches, and `a` and `b` are THE SAME color:
|
|
170
|
+
// a genuine, unambiguous convex corner of P's own region touching one
|
|
171
|
+
// other region. Cut it, revealing that color.
|
|
172
|
+
// - neither `a`, `b`, nor `g` matches, and `a` and `b` DIFFER: three (or
|
|
173
|
+
// four, counting `g`) distinct colors meet at this exact point — e.g. a
|
|
174
|
+
// "T" where one region's straight edge is crossed by the boundary
|
|
175
|
+
// between two others. Left uncut. Rounding here would have to guess
|
|
176
|
+
// which of `a`/`b` "wins", and since the pixel on the OTHER side of
|
|
177
|
+
// that guess is classifying this same point independently — and would
|
|
178
|
+
// guess differently — a pair of pixels each revealing the OTHER's
|
|
179
|
+
// color produces a little criss-crossed notch instead of one clean
|
|
180
|
+
// curve. Leaving every pixel at a 3+-way point sharp keeps it a single
|
|
181
|
+
// consistent (if unrounded) vertex.
|
|
182
|
+
function cornerFill(color, a, b, g) {
|
|
183
|
+
if (a === color || b === color) return NO_CUT;
|
|
184
|
+
if (g === color) return NO_CUT;
|
|
185
|
+
return a === b ? a : NO_CUT;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `color` is always a `"r,g,b,a"` string (0-255 channels, alpha 0-255) —
|
|
189
|
+
// see `colorAt` — never the transparent sentinel (only opaque colors ever
|
|
190
|
+
// reach this function as `color`, only ever as `reveal`).
|
|
191
|
+
function rgbaFillStyle(color) {
|
|
192
|
+
const [r, g, b, a] = color.split(',').map(Number);
|
|
193
|
+
return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Trace the small corner wedge that gets cut from pixel (x, y)'s unit square
|
|
197
|
+
// at `corner` (one of 'TL' | 'TR' | 'BL' | 'BR'): the sliver between the
|
|
198
|
+
// exact corner point and the quarter-circle of radius `r` tangent to both
|
|
199
|
+
// adjacent edges at distance `r` from the corner — i.e. exactly the piece a
|
|
200
|
+
// standard rounded-rect corner removes from a sharp one. `ctx.arcTo`'s
|
|
201
|
+
// corner-point-as-control-point form draws that same tangent arc without
|
|
202
|
+
// hand-computed sweep angles.
|
|
203
|
+
function tracePixelWedge(ctx, corner, x, y, r) {
|
|
204
|
+
if (corner === 'TL') {
|
|
205
|
+
ctx.moveTo(x, y);
|
|
206
|
+
ctx.lineTo(x + r, y);
|
|
207
|
+
ctx.arcTo(x, y, x, y + r, r);
|
|
208
|
+
} else if (corner === 'TR') {
|
|
209
|
+
ctx.moveTo(x + 1, y);
|
|
210
|
+
ctx.lineTo(x + 1, y + r);
|
|
211
|
+
ctx.arcTo(x + 1, y, x + 1 - r, y, r);
|
|
212
|
+
} else if (corner === 'BL') {
|
|
213
|
+
ctx.moveTo(x, y + 1);
|
|
214
|
+
ctx.lineTo(x, y + 1 - r);
|
|
215
|
+
ctx.arcTo(x, y + 1, x + r, y + 1, r);
|
|
216
|
+
} else {
|
|
217
|
+
ctx.moveTo(x + 1, y + 1);
|
|
218
|
+
ctx.lineTo(x + 1 - r, y + 1);
|
|
219
|
+
ctx.arcTo(x + 1, y + 1, x + 1, y + 1 - r, r);
|
|
220
|
+
}
|
|
221
|
+
ctx.closePath();
|
|
222
|
+
}
|
|
@@ -487,6 +487,9 @@
|
|
|
487
487
|
height: 100%;
|
|
488
488
|
display: block;
|
|
489
489
|
touch-action: none;
|
|
490
|
+
/* tabIndex=-1 (keyboard-focus capture on pointer-down) would otherwise draw a
|
|
491
|
+
focus ring; focus here is programmatic only, never via keyboard. */
|
|
492
|
+
outline: none;
|
|
490
493
|
}
|
|
491
494
|
|
|
492
495
|
.stageCanvasEdit {
|
|
@@ -1112,6 +1115,18 @@
|
|
|
1112
1115
|
max-height: 100%;
|
|
1113
1116
|
}
|
|
1114
1117
|
|
|
1118
|
+
.canvasTopStack {
|
|
1119
|
+
display: flex;
|
|
1120
|
+
flex-direction: column;
|
|
1121
|
+
gap: 6px;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.canvasTopBar {
|
|
1125
|
+
display: flex;
|
|
1126
|
+
align-items: center;
|
|
1127
|
+
gap: 16px;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1115
1130
|
.canvasSizeBar {
|
|
1116
1131
|
display: flex;
|
|
1117
1132
|
align-items: center;
|
|
@@ -1121,6 +1136,54 @@
|
|
|
1121
1136
|
color: var(--castle-inspector-muted);
|
|
1122
1137
|
}
|
|
1123
1138
|
|
|
1139
|
+
.cornerRadiusBar {
|
|
1140
|
+
display: flex;
|
|
1141
|
+
align-items: center;
|
|
1142
|
+
gap: 6px;
|
|
1143
|
+
flex: 0 0 auto;
|
|
1144
|
+
font-size: 12px;
|
|
1145
|
+
color: var(--castle-inspector-muted);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/* Corner-radius segmented control (0 / ¼ / ½) — same bordered-row-of-
|
|
1149
|
+
buttons pattern as .drawingShapeModeRow, sized for short text labels
|
|
1150
|
+
instead of square glyph buttons. */
|
|
1151
|
+
.cornerRadiusSegments {
|
|
1152
|
+
display: flex;
|
|
1153
|
+
border: 1px solid var(--castle-inspector-border);
|
|
1154
|
+
border-radius: var(--castle-radius);
|
|
1155
|
+
overflow: hidden;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.cornerRadiusSegments > * + * {
|
|
1159
|
+
border-left: 1px solid var(--castle-inspector-border);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
.cornerRadiusSegment {
|
|
1163
|
+
min-width: 28px;
|
|
1164
|
+
padding: 3px 8px;
|
|
1165
|
+
border: 0;
|
|
1166
|
+
background: var(--castle-inspector-button-bg);
|
|
1167
|
+
color: var(--castle-inspector-text);
|
|
1168
|
+
cursor: pointer;
|
|
1169
|
+
font-size: 12px;
|
|
1170
|
+
font-variant-numeric: tabular-nums;
|
|
1171
|
+
line-height: 1.35;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
.cornerRadiusSegment:hover {
|
|
1175
|
+
background: var(--castle-inspector-input-bg);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/* Compound selector (not a bare `.cornerRadiusSegmentOn`) for the same reason
|
|
1179
|
+
documented above `.drawingToolButton.drawingToolSelected`: it must beat
|
|
1180
|
+
`.cornerRadiusSegment:hover` on specificity no matter source order. */
|
|
1181
|
+
.cornerRadiusSegment.cornerRadiusSegmentOn,
|
|
1182
|
+
.cornerRadiusSegment.cornerRadiusSegmentOn:hover {
|
|
1183
|
+
background: var(--castle-selected);
|
|
1184
|
+
color: var(--castle-selected-ink);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1124
1187
|
.canvasSizeSep {
|
|
1125
1188
|
color: var(--castle-inspector-muted);
|
|
1126
1189
|
user-select: none;
|
|
@@ -1306,23 +1369,20 @@
|
|
|
1306
1369
|
justify-content: center;
|
|
1307
1370
|
}
|
|
1308
1371
|
|
|
1309
|
-
/* Stacks the native artboard canvas
|
|
1310
|
-
marching-ants outline renders crisp
|
|
1372
|
+
/* Stacks the native artboard canvas, the smooth-preview canvas, and the
|
|
1373
|
+
display-resolution overlay so the marching-ants outline renders crisp
|
|
1374
|
+
instead of sub-pixel on the upscaled art. The checkerboard "transparency"
|
|
1375
|
+
background lives on the FRAME (not the main canvas) so it doesn't paint
|
|
1376
|
+
over the smooth-preview canvas underneath in "smooth" render mode. */
|
|
1311
1377
|
.drawingArtboardFrame {
|
|
1312
1378
|
position: relative;
|
|
1313
1379
|
flex: 0 0 auto;
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
height: 100%;
|
|
1321
|
-
pointer-events: none;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
.drawingCanvas {
|
|
1325
|
-
image-rendering: pixelated;
|
|
1380
|
+
/* Own stacking context, so the negative-z .drawingSmooth canvas paints just
|
|
1381
|
+
above THIS element's checkerboard background instead of sinking below it
|
|
1382
|
+
(without isolation, negative z-index children paint before all in-flow
|
|
1383
|
+
descendant backgrounds of the outer stacking context — checkerboard
|
|
1384
|
+
included — hiding the smooth render entirely). */
|
|
1385
|
+
isolation: isolate;
|
|
1326
1386
|
background:
|
|
1327
1387
|
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
|
1328
1388
|
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
|
@@ -1336,7 +1396,41 @@
|
|
|
1336
1396
|
8px -8px,
|
|
1337
1397
|
-8px 0;
|
|
1338
1398
|
border: 1px solid var(--castle-inspector-border);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
.drawingOverlay {
|
|
1402
|
+
position: absolute;
|
|
1403
|
+
inset: 0;
|
|
1404
|
+
width: 100%;
|
|
1405
|
+
height: 100%;
|
|
1406
|
+
pointer-events: none;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/* Behind the main canvas (negative z-index paints before static content in
|
|
1410
|
+
the same containing block) — holds the corner-rounded render for "smooth"
|
|
1411
|
+
sprites while the main canvas above it stays transparent except for tool
|
|
1412
|
+
overlays, so editing interactions are unaffected. Empty/0-sized in "pixel"
|
|
1413
|
+
mode. */
|
|
1414
|
+
.drawingSmooth {
|
|
1415
|
+
position: absolute;
|
|
1416
|
+
inset: 0;
|
|
1417
|
+
width: 100%;
|
|
1418
|
+
height: 100%;
|
|
1419
|
+
pointer-events: none;
|
|
1420
|
+
z-index: -1;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
.drawingCanvas {
|
|
1424
|
+
image-rendering: pixelated;
|
|
1425
|
+
position: absolute;
|
|
1426
|
+
inset: 0;
|
|
1427
|
+
width: 100%;
|
|
1428
|
+
height: 100%;
|
|
1339
1429
|
flex: 0 0 auto;
|
|
1430
|
+
/* tabIndex=-1 (for keyboard-focus capture on pointer-down) would otherwise
|
|
1431
|
+
draw a focus ring around the artboard; it's focused programmatically, never
|
|
1432
|
+
via keyboard, so suppressing the outline is safe. */
|
|
1433
|
+
outline: none;
|
|
1340
1434
|
}
|
|
1341
1435
|
|
|
1342
1436
|
.palette {
|
package/package.json
CHANGED