castle-web-cli 0.4.71 → 0.4.73
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.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/* Layers x frames timeline panel for the PxArt editor. Sits below the artboard
|
|
2
|
+
in the editor's left column: a toolbar row, a horizontally-scrolling
|
|
3
|
+
layers(rows) x frames(columns) grid, then the tags panel. Colors reuse the
|
|
4
|
+
shared inspector CSS variables from engine/ui.module.css. */
|
|
5
|
+
|
|
6
|
+
/* The editor's left column: artboard region stacked above the timeline. Takes
|
|
7
|
+
grid column 1 of the shared .drawingEditor grid (the inspector is column 2). */
|
|
8
|
+
.editorLeft {
|
|
9
|
+
grid-column: 1;
|
|
10
|
+
grid-row: 1;
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
min-height: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.artboardRegion {
|
|
18
|
+
flex: 1 1 0;
|
|
19
|
+
min-height: 0;
|
|
20
|
+
display: flex;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@container editor (max-width: 620px) {
|
|
25
|
+
.barLabel {
|
|
26
|
+
display: none;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Height is driven by an inline style from the resize handle (see
|
|
31
|
+
pxArtTimeline.jsx); flex-shrink stays 0 so the artboard region above keeps
|
|
32
|
+
the remainder. */
|
|
33
|
+
.timeline {
|
|
34
|
+
position: relative;
|
|
35
|
+
flex: 0 0 auto;
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
min-height: 0;
|
|
39
|
+
border-top: 1px solid var(--castle-inspector-divider);
|
|
40
|
+
background: var(--castle-workspace-bg);
|
|
41
|
+
color: var(--castle-inspector-text);
|
|
42
|
+
font-size: 13px;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Drag target sitting over the top divider. Absolute so it consumes no layout.
|
|
47
|
+
Neutral hover/active treatment (no accent/purple highlight) to stay
|
|
48
|
+
consistent with the editor's existing chrome. */
|
|
49
|
+
.resizeHandle {
|
|
50
|
+
position: absolute;
|
|
51
|
+
top: -3px;
|
|
52
|
+
left: 0;
|
|
53
|
+
right: 0;
|
|
54
|
+
height: 7px;
|
|
55
|
+
cursor: row-resize;
|
|
56
|
+
background: transparent;
|
|
57
|
+
z-index: 5;
|
|
58
|
+
touch-action: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.resizeHandle:hover {
|
|
62
|
+
background: var(--castle-inspector-border);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.resizeHandleActive,
|
|
66
|
+
.resizeHandleActive:hover {
|
|
67
|
+
background: var(--castle-inspector-muted);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.bar {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
flex-wrap: wrap;
|
|
74
|
+
gap: 14px;
|
|
75
|
+
padding: 8px;
|
|
76
|
+
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.barGroup {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 6px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.barLabel {
|
|
86
|
+
color: var(--castle-inspector-muted);
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
letter-spacing: 0.04em;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.barSpacer {
|
|
93
|
+
flex: 1 1 auto;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.barUnit {
|
|
97
|
+
color: var(--castle-inspector-muted);
|
|
98
|
+
font-size: 11px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.globalDuration {
|
|
102
|
+
width: 56px;
|
|
103
|
+
border: 1px solid var(--castle-inspector-border);
|
|
104
|
+
border-radius: var(--castle-radius);
|
|
105
|
+
background: var(--castle-inspector-input-bg);
|
|
106
|
+
color: var(--castle-inspector-text);
|
|
107
|
+
padding: 4px 6px;
|
|
108
|
+
text-align: center;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Icon-only label (stopwatch) sitting next to the global duration input, in
|
|
112
|
+
place of the old "Duration … ms · all frames" text. */
|
|
113
|
+
.durationIcon {
|
|
114
|
+
display: inline-flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
color: var(--castle-inspector-muted);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.durationGlyph {
|
|
120
|
+
width: 14px;
|
|
121
|
+
height: 14px;
|
|
122
|
+
fill: currentColor;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.miniInput {
|
|
126
|
+
width: 64px;
|
|
127
|
+
border: 1px solid var(--castle-inspector-border);
|
|
128
|
+
border-radius: var(--castle-radius);
|
|
129
|
+
background: var(--castle-inspector-input-bg);
|
|
130
|
+
color: var(--castle-inspector-text);
|
|
131
|
+
padding: 4px 6px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.scroll {
|
|
135
|
+
flex: 1 1 0;
|
|
136
|
+
min-height: 0;
|
|
137
|
+
overflow: auto;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.grid {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
min-width: 100%;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.row {
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: stretch;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Grid separators: every grid element (corner, frame headers, layer headers,
|
|
152
|
+
and art cells) draws a SINGLE 1px right+bottom divider. Interior edges are
|
|
153
|
+
each owned by exactly one element (no two borders meet), so there are no
|
|
154
|
+
doubled/thicker seams. The grid's outer top is the toolbar's existing bottom
|
|
155
|
+
divider; outer right/bottom come from the last cells. */
|
|
156
|
+
.corner {
|
|
157
|
+
flex: 0 0 168px;
|
|
158
|
+
border-right: 1px solid var(--castle-inspector-divider);
|
|
159
|
+
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
160
|
+
background: var(--castle-workspace-bg);
|
|
161
|
+
position: sticky;
|
|
162
|
+
left: 0;
|
|
163
|
+
z-index: 2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.frameHead {
|
|
167
|
+
flex: 0 0 52px;
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 2px;
|
|
172
|
+
padding: 4px 3px;
|
|
173
|
+
border-right: 1px solid var(--castle-inspector-divider);
|
|
174
|
+
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
175
|
+
background: transparent;
|
|
176
|
+
color: var(--castle-inspector-muted);
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.frameHeadActive {
|
|
181
|
+
background: var(--castle-black);
|
|
182
|
+
color: var(--castle-text);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.frameNum {
|
|
186
|
+
font-variant-numeric: tabular-nums;
|
|
187
|
+
font-size: 12px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.durationInput {
|
|
191
|
+
width: 44px;
|
|
192
|
+
border: 1px solid var(--castle-inspector-border);
|
|
193
|
+
border-radius: 3px;
|
|
194
|
+
background: var(--castle-inspector-input-bg);
|
|
195
|
+
color: var(--castle-inspector-text);
|
|
196
|
+
padding: 1px 3px;
|
|
197
|
+
font-size: 11px;
|
|
198
|
+
text-align: center;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* An OVERRIDDEN frame duration: accented + bold so it reads distinctly from the
|
|
202
|
+
greyed placeholder shown by frames that inherit the global. */
|
|
203
|
+
.durationOverride {
|
|
204
|
+
color: var(--castle-text);
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
border-color: var(--castle-blue);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.layerHead {
|
|
210
|
+
flex: 0 0 168px;
|
|
211
|
+
display: flex;
|
|
212
|
+
flex-direction: column;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
gap: 4px;
|
|
215
|
+
padding: 4px 8px;
|
|
216
|
+
border-right: 1px solid var(--castle-inspector-divider);
|
|
217
|
+
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
218
|
+
background: var(--castle-workspace-bg);
|
|
219
|
+
position: sticky;
|
|
220
|
+
left: 0;
|
|
221
|
+
z-index: 1;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.layerHeadActive {
|
|
226
|
+
background: var(--castle-black);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.layerTopRow {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 6px;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.visBtn {
|
|
236
|
+
flex: 0 0 auto;
|
|
237
|
+
width: 22px;
|
|
238
|
+
height: 22px;
|
|
239
|
+
display: inline-flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
justify-content: center;
|
|
242
|
+
border: 1px solid var(--castle-inspector-border);
|
|
243
|
+
border-radius: var(--castle-radius);
|
|
244
|
+
background: var(--castle-inspector-button-bg);
|
|
245
|
+
color: var(--castle-inspector-text);
|
|
246
|
+
cursor: pointer;
|
|
247
|
+
padding: 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.visBtn:hover {
|
|
251
|
+
background: var(--castle-inspector-input-bg);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.visBtn svg {
|
|
255
|
+
width: 12px;
|
|
256
|
+
height: 12px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Inline FA eye / eye-slash glyph for the visibility toggle (see FaGlyph). */
|
|
260
|
+
.visGlyph {
|
|
261
|
+
width: 12px;
|
|
262
|
+
height: 12px;
|
|
263
|
+
fill: currentColor;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Hidden layer: the visibility toggle borrows the standard disabled LOOK
|
|
267
|
+
(0.35 dimming, matching the undo button) without being disabled -- this
|
|
268
|
+
button stays interactive and toggles default <-> disabled-look. */
|
|
269
|
+
.visOff {
|
|
270
|
+
opacity: 0.35;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* Static layer-name label (rename was removed from the UI). */
|
|
274
|
+
.name {
|
|
275
|
+
flex: 1 1 auto;
|
|
276
|
+
min-width: 0;
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
text-overflow: ellipsis;
|
|
279
|
+
white-space: nowrap;
|
|
280
|
+
background: none;
|
|
281
|
+
border: 0;
|
|
282
|
+
color: inherit;
|
|
283
|
+
text-align: left;
|
|
284
|
+
padding: 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Slim opacity slider: a thin custom track + small thumb so the layer header
|
|
288
|
+
cell stays no taller than the art (thumbnail) cells in its row. */
|
|
289
|
+
.opacity {
|
|
290
|
+
width: 100%;
|
|
291
|
+
height: 12px;
|
|
292
|
+
margin: 0;
|
|
293
|
+
cursor: pointer;
|
|
294
|
+
appearance: none;
|
|
295
|
+
-webkit-appearance: none;
|
|
296
|
+
background: transparent;
|
|
297
|
+
accent-color: var(--castle-selected);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.opacity::-webkit-slider-runnable-track {
|
|
301
|
+
height: 3px;
|
|
302
|
+
border-radius: 2px;
|
|
303
|
+
background: var(--castle-inspector-border);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.opacity::-webkit-slider-thumb {
|
|
307
|
+
-webkit-appearance: none;
|
|
308
|
+
appearance: none;
|
|
309
|
+
width: 10px;
|
|
310
|
+
height: 10px;
|
|
311
|
+
margin-top: -3.5px;
|
|
312
|
+
border-radius: 50%;
|
|
313
|
+
background: var(--castle-selected);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.opacity::-moz-range-track {
|
|
317
|
+
height: 3px;
|
|
318
|
+
border-radius: 2px;
|
|
319
|
+
background: var(--castle-inspector-border);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.opacity::-moz-range-thumb {
|
|
323
|
+
width: 10px;
|
|
324
|
+
height: 10px;
|
|
325
|
+
border: 0;
|
|
326
|
+
border-radius: 50%;
|
|
327
|
+
background: var(--castle-selected);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* The cell is a real <button>, so wipe the UA button chrome first. Crucially
|
|
331
|
+
`border: 0` clears the default TOP/LEFT borders the UA otherwise keeps (we only
|
|
332
|
+
override right/bottom below) — those surviving defaults were the "heavier /
|
|
333
|
+
doubled" top-left edge. The reset comes BEFORE the divider longhands so our
|
|
334
|
+
right+bottom separators still apply; the selected-cell inset stroke lives on
|
|
335
|
+
.cellActive (box-shadow) and is unaffected by this border reset. */
|
|
336
|
+
.cell {
|
|
337
|
+
appearance: none;
|
|
338
|
+
-webkit-appearance: none;
|
|
339
|
+
margin: 0;
|
|
340
|
+
border: 0;
|
|
341
|
+
background: none;
|
|
342
|
+
font: inherit;
|
|
343
|
+
color: inherit;
|
|
344
|
+
text-align: center;
|
|
345
|
+
box-sizing: border-box;
|
|
346
|
+
flex: 0 0 52px;
|
|
347
|
+
height: 52px;
|
|
348
|
+
display: flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
justify-content: center;
|
|
351
|
+
padding: 4px;
|
|
352
|
+
border-right: 1px solid var(--castle-inspector-divider);
|
|
353
|
+
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
position: relative;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.cellActive {
|
|
359
|
+
background: rgb(141 183 255 / 0.16);
|
|
360
|
+
box-shadow: inset 0 0 0 2px var(--castle-blue);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.thumb {
|
|
364
|
+
width: 100%;
|
|
365
|
+
height: 100%;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.thumbCanvas {
|
|
369
|
+
width: 100%;
|
|
370
|
+
height: 100%;
|
|
371
|
+
image-rendering: pixelated;
|
|
372
|
+
display: block;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* Empty/absent cell: plain and empty. The cell's own grid separators (see the
|
|
376
|
+
.corner/.cell border rules) still bound it; there's no dashed interior. */
|
|
377
|
+
.empty {
|
|
378
|
+
width: 100%;
|
|
379
|
+
height: 100%;
|
|
380
|
+
background: transparent;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.linkBadge {
|
|
384
|
+
position: absolute;
|
|
385
|
+
left: 5px;
|
|
386
|
+
top: 5px;
|
|
387
|
+
width: 14px;
|
|
388
|
+
height: 14px;
|
|
389
|
+
display: inline-flex;
|
|
390
|
+
align-items: center;
|
|
391
|
+
justify-content: center;
|
|
392
|
+
/* Match the thumb/.linkRing corner rounding (2px). A larger radius left a
|
|
393
|
+
sliver of the parent's rounded corner peeking behind the badge. */
|
|
394
|
+
border-radius: 2px;
|
|
395
|
+
background: var(--castle-blue);
|
|
396
|
+
color: var(--castle-selected-ink);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.linkBadge svg {
|
|
400
|
+
width: 9px;
|
|
401
|
+
height: 9px;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.linkRing {
|
|
405
|
+
box-shadow: inset 0 0 0 1px var(--castle-blue);
|
|
406
|
+
border-radius: 2px;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* Fade only the linked cell's thumbnail IMAGE (the canvas) to signal a shared
|
|
410
|
+
image. Scoped to the canvas inside a linked thumb, so the blue ring
|
|
411
|
+
(.linkRing box-shadow) and the link badge (.linkBadge) stay full opacity. */
|
|
412
|
+
.linkRing .thumbCanvas {
|
|
413
|
+
opacity: 0.5;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.tags {
|
|
417
|
+
flex: 0 0 auto;
|
|
418
|
+
max-height: 38%;
|
|
419
|
+
overflow: auto;
|
|
420
|
+
border-top: 1px solid var(--castle-inspector-divider);
|
|
421
|
+
padding: 8px 12px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.tagsHead {
|
|
425
|
+
display: flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
gap: 10px;
|
|
428
|
+
margin-bottom: 8px;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.tagRow {
|
|
432
|
+
display: flex;
|
|
433
|
+
align-items: center;
|
|
434
|
+
gap: 6px;
|
|
435
|
+
margin-bottom: 6px;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.tagName {
|
|
439
|
+
flex: 1 1 90px;
|
|
440
|
+
min-width: 0;
|
|
441
|
+
border: 1px solid var(--castle-inspector-border);
|
|
442
|
+
border-radius: 3px;
|
|
443
|
+
background: var(--castle-inspector-input-bg);
|
|
444
|
+
color: var(--castle-inspector-text);
|
|
445
|
+
padding: 3px 6px;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.tagNum {
|
|
449
|
+
width: 46px;
|
|
450
|
+
border: 1px solid var(--castle-inspector-border);
|
|
451
|
+
border-radius: 3px;
|
|
452
|
+
background: var(--castle-inspector-input-bg);
|
|
453
|
+
color: var(--castle-inspector-text);
|
|
454
|
+
padding: 3px 4px;
|
|
455
|
+
text-align: center;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.tagSelect {
|
|
459
|
+
border: 1px solid var(--castle-inspector-border);
|
|
460
|
+
border-radius: 3px;
|
|
461
|
+
background: var(--castle-inspector-input-bg);
|
|
462
|
+
color: var(--castle-inspector-text);
|
|
463
|
+
padding: 3px 4px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.tagDefault {
|
|
467
|
+
flex: 0 0 auto;
|
|
468
|
+
width: 24px;
|
|
469
|
+
height: 24px;
|
|
470
|
+
display: inline-flex;
|
|
471
|
+
align-items: center;
|
|
472
|
+
justify-content: center;
|
|
473
|
+
border: 1px solid var(--castle-inspector-border);
|
|
474
|
+
border-radius: var(--castle-radius);
|
|
475
|
+
background: var(--castle-inspector-button-bg);
|
|
476
|
+
color: var(--castle-inspector-muted);
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
padding: 0;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.tagDefault:hover {
|
|
482
|
+
background: var(--castle-inspector-input-bg);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.tagDefaultOn,
|
|
486
|
+
.tagDefaultOn:hover {
|
|
487
|
+
background: var(--castle-selected);
|
|
488
|
+
border-color: var(--castle-selected);
|
|
489
|
+
color: var(--castle-selected-ink);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.tagDefault svg {
|
|
493
|
+
width: 12px;
|
|
494
|
+
height: 12px;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.muted {
|
|
498
|
+
color: var(--castle-inspector-muted);
|
|
499
|
+
font-size: 12px;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* Disabled right-click menu item (reuses the shared selArrangeItem chrome). */
|
|
503
|
+
.menuItemDisabled {
|
|
504
|
+
opacity: 0.4;
|
|
505
|
+
cursor: default;
|
|
506
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { EDG64, KEY_ALPHABET, normalizeHex, TRANSPARENT } from '../engine/pxart';
|
|
2
|
+
import { forEachDab, forEachLinePoint } from './pixelGeometry';
|
|
3
|
+
|
|
4
|
+
// The PxArt editor paints over the FIXED full Endesga-64 palette (no free color
|
|
5
|
+
// picking). Each palette color gets a stable single-char key (KEY_ALPHABET, 64
|
|
6
|
+
// distinct keys); the transparent cell is the reserved "." (TRANSPARENT).
|
|
7
|
+
export const EDITOR_KEYS = EDG64.map((_, index) => KEY_ALPHABET[index]);
|
|
8
|
+
|
|
9
|
+
// key -> "#rrggbb" record, in palette order. Passed to the SDK's fromCells so
|
|
10
|
+
// serialized files carry only the palette entries actually used.
|
|
11
|
+
export const EDITOR_PALETTE = Object.fromEntries(
|
|
12
|
+
EDG64.map((hex, index) => [EDITOR_KEYS[index], hex])
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// hex -> palette key, for mapping a loaded sprite's colors onto the swatch.
|
|
16
|
+
const KEY_BY_HEX = new Map(EDG64.map((hex, index) => [hex, EDITOR_KEYS[index]]));
|
|
17
|
+
|
|
18
|
+
// Build a `height x width` matrix of "." (transparent) cells.
|
|
19
|
+
export function blankCells(width, height) {
|
|
20
|
+
return Array.from({ length: height }, () => Array.from({ length: width }, () => TRANSPARENT));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Snap an arbitrary hex color to the nearest palette key by RGB distance, so a
|
|
24
|
+
// loaded sprite whose palette isn't exactly the Endesga-64 set still maps onto
|
|
25
|
+
// the fixed swatch. Returns "." for invalid/transparent input.
|
|
26
|
+
export function editorKeyForHex(hex) {
|
|
27
|
+
const norm = typeof hex === 'string' ? normalizeHex(hex) : null;
|
|
28
|
+
if (!norm) return TRANSPARENT;
|
|
29
|
+
const exact = KEY_BY_HEX.get('#' + norm.slice(1, 7));
|
|
30
|
+
if (exact) return exact;
|
|
31
|
+
const r = parseInt(norm.slice(1, 3), 16);
|
|
32
|
+
const g = parseInt(norm.slice(3, 5), 16);
|
|
33
|
+
const b = parseInt(norm.slice(5, 7), 16);
|
|
34
|
+
let bestKey = EDITOR_KEYS[0];
|
|
35
|
+
let bestDist = Infinity;
|
|
36
|
+
EDG64.forEach((swatch, index) => {
|
|
37
|
+
const dist =
|
|
38
|
+
(r - parseInt(swatch.slice(1, 3), 16)) ** 2 +
|
|
39
|
+
(g - parseInt(swatch.slice(3, 5), 16)) ** 2 +
|
|
40
|
+
(b - parseInt(swatch.slice(5, 7), 16)) ** 2;
|
|
41
|
+
if (dist < bestDist) {
|
|
42
|
+
bestDist = dist;
|
|
43
|
+
bestKey = EDITOR_KEYS[index];
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return bestKey;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isInside(cells, x, y) {
|
|
50
|
+
return y >= 0 && y < cells.length && x >= 0 && x < (cells[0]?.length ?? 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function copyCells(cells) {
|
|
54
|
+
return cells.map((row) => row.slice());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Paint a single cell in a working matrix; returns true when it changed.
|
|
58
|
+
function writeCell(cells, x, y, key) {
|
|
59
|
+
if (!isInside(cells, x, y) || cells[y][x] === key) return false;
|
|
60
|
+
cells[y][x] = key;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Paint `key` along the segment from `from` to `to` with a brush of `size`
|
|
65
|
+
// cells (1 = single cell). Returns a new matrix when anything changed, else the
|
|
66
|
+
// original (so callers can skip no-op commits).
|
|
67
|
+
export function paintLine(cells, from, to, key, size = 1) {
|
|
68
|
+
const next = copyCells(cells);
|
|
69
|
+
let changed = false;
|
|
70
|
+
forEachLinePoint(from, to, (cx, cy) => {
|
|
71
|
+
forEachDab(size, (dx, dy) => {
|
|
72
|
+
changed = writeCell(next, cx + dx, cy + dy, key) || changed;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return changed ? next : cells;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Both region tools share the same guard: bail when the start is out of bounds
|
|
79
|
+
// or already the target key, else return a working copy plus the key to replace.
|
|
80
|
+
function beginKeyReplace(cells, start, key) {
|
|
81
|
+
if (!isInside(cells, start.x, start.y)) return null;
|
|
82
|
+
const target = cells[start.y][start.x];
|
|
83
|
+
if (target === key) return null;
|
|
84
|
+
return { next: copyCells(cells), target };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Flood-fill the contiguous region matching the start cell's key with `key`.
|
|
88
|
+
// Passing TRANSPARENT erases the contiguous region (the "fill mode" eraser).
|
|
89
|
+
export function fillRegion(cells, start, key) {
|
|
90
|
+
const begin = beginKeyReplace(cells, start, key);
|
|
91
|
+
if (!begin) return cells;
|
|
92
|
+
const { next, target } = begin;
|
|
93
|
+
const queue = [start];
|
|
94
|
+
while (queue.length) {
|
|
95
|
+
const { x, y } = queue.pop();
|
|
96
|
+
if (!isInside(next, x, y) || next[y][x] !== target) continue;
|
|
97
|
+
next[y][x] = key;
|
|
98
|
+
queue.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 });
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recolor EVERY cell matching the start cell's key to `key` (global swap): a
|
|
104
|
+
// fixed-palette palette-swap fill mode.
|
|
105
|
+
export function swapKey(cells, start, key) {
|
|
106
|
+
const begin = beginKeyReplace(cells, start, key);
|
|
107
|
+
if (!begin) return cells;
|
|
108
|
+
const { next, target } = begin;
|
|
109
|
+
let changed = false;
|
|
110
|
+
for (let y = 0; y < next.length; y++) {
|
|
111
|
+
const row = next[y];
|
|
112
|
+
for (let x = 0; x < row.length; x++) {
|
|
113
|
+
if (row[x] !== target) continue;
|
|
114
|
+
row[x] = key;
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return changed ? next : cells;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The palette key under a cell (for the eyedropper), or "." when transparent.
|
|
122
|
+
export function sampleKey(cells, point) {
|
|
123
|
+
return isInside(cells, point.x, point.y) ? cells[point.y][point.x] : TRANSPARENT;
|
|
124
|
+
}
|