Pixel art tools show only useful options and remove any colour (#201)
The pixel art side bar showed every tool option all the time, the palette used only part of the column width and "Remove" could only drop the last colour. Now each tool shows what it needs, the palette fills the column and any colour can be removed. - Options per tool live in toolOptions.ts. Brush shows for pencil, eraser, line and unfilled shapes. Filled shapes shows for rectangle and ellipse. Contiguous shows for fill. Picker has none. - All options share one row with a fixed height, so nothing below jumps when the tool changes. - The palette picks its column count from the width and stretches the swatches until the row ends flush. At 1280 px that is 11 per row instead of 8. - The colour field takes the space the two buttons leave, so "Remove" is no longer cut off. - "Remove" deletes the selected colour. Pixels of that colour turn transparent, later indices move down by one in every image that uses the palette, and palette plus images change in one undo step. Transparent itself cannot be removed. Decisions: - Pixels of a removed colour become transparent, like erasing (chosen by the user before the build). Findings: - The Filled checkbox slid away under the pointer. The brush button sat to its left and vanished when Filled turned on, so the second click missed. The brush button now sits to the right, and a check clicks the same spot twice. - One start-up FAIL "debug hook present" came back never in three later runs. It is the known start flake. Tests: - ./check.sh: 141 files, 2080 tests, exit 0 (before 140 / 2070) - 10 new unit tests in tests/editor/pixel-palette.test.ts - GM_STEPS=48 (fresh seed): ALL PASSED, 40 checks, 4 of them new - Counter-check: indices not shifting and brush always visible make 4 unit tests and exactly the 2 matching e2e checks fail - Check total 1282 (1278 + 4) in README and wiki, computed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41772e53f7
commit
fe894c630d
10 files changed
+424
-55
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1278 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1282 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ export function resizeCel(cel: CelPixels, width: number, height: number, anchor:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cel once palette colour `index` is gone (#201): its pixels turn transparent and every colour
|
||||||
|
* after it moves down one, so each remaining pixel keeps its colour. Null when nothing changes.
|
||||||
|
*/
|
||||||
|
export function withoutColorIndex(cel: CelPixels, index: number): CelPixels | null {
|
||||||
|
if (!cel.data.some((i) => i >= index)) return null;
|
||||||
|
const out = cloneCel(cel);
|
||||||
|
for (let p = 0; p < out.data.length; p++) {
|
||||||
|
const i = out.data[p]!;
|
||||||
|
if (i === index) out.data[p] = 0;
|
||||||
|
else if (i > index) out.data[p] = i - 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The layer below with the one above laid over it: every pixel the upper layer paints covers the
|
* The layer below with the one above laid over it: every pixel the upper layer paints covers the
|
||||||
* lower one. Indexed colours have no in-between colour, so the upper layer's opacity cannot be kept –
|
* lower one. Indexed colours have no in-between colour, so the upper layer's opacity cannot be kept –
|
||||||
|
|||||||
@@ -207,14 +207,23 @@ export function withCanvasSize(
|
|||||||
): PixelArtState {
|
): PixelArtState {
|
||||||
if (!isCanvasSide(width) || !isCanvasSide(height))
|
if (!isCanvasSide(width) || !isCanvasSide(height))
|
||||||
throw new PixelEditError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`);
|
throw new PixelEditError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`);
|
||||||
|
for (const frame of state.frames)
|
||||||
|
for (const file of Object.values(frame.cels))
|
||||||
|
if (cels[file] === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart');
|
||||||
|
return { ...withCelsReplaced(state, cels), width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every cel `cels` names swapped for the file it maps to, `''` leaving it empty; the others stay. */
|
||||||
|
export function withCelsReplaced(
|
||||||
|
state: PixelArtState,
|
||||||
|
cels: Readonly<Record<string, string>>,
|
||||||
|
): PixelArtState {
|
||||||
const next = cloneState(state);
|
const next = cloneState(state);
|
||||||
next.width = width;
|
|
||||||
next.height = height;
|
|
||||||
for (const frame of next.frames)
|
for (const frame of next.frames)
|
||||||
for (const [layerId, file] of Object.entries(frame.cels)) {
|
for (const [layerId, file] of Object.entries(frame.cels)) {
|
||||||
const resized = cels[file];
|
const replaced = cels[file];
|
||||||
if (resized === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart');
|
if (replaced === undefined) continue;
|
||||||
if (resized) frame.cels[layerId] = resized;
|
if (replaced) frame.cels[layerId] = replaced;
|
||||||
else delete frame.cels[layerId];
|
else delete frame.cels[layerId];
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -519,14 +519,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
return at ? { ...at, enabled: true, text: '+' } : null;
|
return at ? { ...at, enabled: true, text: '+' } : null;
|
||||||
}
|
}
|
||||||
const widget = byName[name];
|
const widget = byName[name];
|
||||||
if (!widget || !widget.visible) return null;
|
// A tool's options hide in their row (#201), so a hidden parent hides the control as well.
|
||||||
|
for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null;
|
||||||
|
if (!widget) return null;
|
||||||
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
|
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
|
||||||
return {
|
return {
|
||||||
...centreOf(widget),
|
...centreOf(widget),
|
||||||
|
w: widget.w,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
text: widget instanceof Button ? widget.text : '',
|
text: widget instanceof Button ? widget.text : '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/** Where the controls of the side panel may reach, left and right. */
|
||||||
|
sideSpan: () => editor.pixelArtEditor.side.contentSpan(),
|
||||||
swatch: (index: number) => {
|
swatch: (index: number) => {
|
||||||
const side = editor.pixelArtEditor.side;
|
const side = editor.pixelArtEditor.side;
|
||||||
side.reveal(side.palette);
|
side.reveal(side.palette);
|
||||||
|
|||||||
@@ -3,18 +3,33 @@ import { type Size, Theme, Widget } from '../../ui';
|
|||||||
|
|
||||||
const CELL = 20;
|
const CELL = 20;
|
||||||
const GAP = 2;
|
const GAP = 2;
|
||||||
export const PALETTE_COLUMNS = 8;
|
|
||||||
|
export interface PaletteLayout {
|
||||||
|
columns: number;
|
||||||
|
/** The side of a swatch: at least `CELL`, grown so the row ends flush with the width. */
|
||||||
|
cell: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As many swatches to a row as the width holds (#201); a width not known yet lays out eight. */
|
||||||
|
export function paletteLayout(width: number, swatches: number): PaletteLayout {
|
||||||
|
const columns = width > 0 ? Math.max(1, Math.floor((width + GAP) / (CELL + GAP))) : 8;
|
||||||
|
const cell = width > 0 ? Math.max(CELL, Math.floor((width - (columns - 1) * GAP) / columns)) : CELL;
|
||||||
|
return { columns, cell, rows: Math.max(1, Math.ceil(swatches / columns)) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colours of a palette as swatches, eight to a row (#130). The first swatch is index 0 –
|
* The colours of a palette as swatches across the whole width (#130, #201). The first swatch is
|
||||||
* transparent, drawn as a checkerboard – and the others follow the palette in order, so a swatch's
|
* index 0 – transparent, drawn as a checkerboard – and the others follow the palette in order, so a
|
||||||
* place is its index. A click picks it; the picked one is outlined.
|
* swatch's place is its index. A click picks it; the picked one is outlined.
|
||||||
*/
|
*/
|
||||||
export class PaletteGrid extends Widget {
|
export class PaletteGrid extends Widget {
|
||||||
private readonly swatches = new Graphics();
|
private readonly swatches = new Graphics();
|
||||||
private colors: readonly number[] = [];
|
private colors: readonly number[] = [];
|
||||||
private picked = 1;
|
private picked = 1;
|
||||||
onPick: (index: number) => void = () => {};
|
onPick: (index: number) => void = () => {};
|
||||||
|
/** Told when the swatches need another number of rows, so the column around lays out again. */
|
||||||
|
onReflow: () => void = () => {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -22,58 +37,69 @@ export class PaletteGrid extends Widget {
|
|||||||
this.cursor = 'pointer';
|
this.cursor = 'pointer';
|
||||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||||
const local = this.toLocal(e.global);
|
const local = this.toLocal(e.global);
|
||||||
const column = Math.floor(local.x / (CELL + GAP));
|
const { columns, cell } = this.grid();
|
||||||
const row = Math.floor(local.y / (CELL + GAP));
|
const column = Math.floor(local.x / (cell + GAP));
|
||||||
const index = row * PALETTE_COLUMNS + column;
|
const row = Math.floor(local.y / (cell + GAP));
|
||||||
if (column < PALETTE_COLUMNS && index <= this.colors.length) this.onPick(index);
|
const index = row * columns + column;
|
||||||
|
if (column < columns && index <= this.colors.length) this.onPick(index);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private grid(): PaletteLayout {
|
||||||
|
return paletteLayout(this.w, this.colors.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
setColors(colors: readonly number[], picked: number): void {
|
setColors(colors: readonly number[], picked: number): void {
|
||||||
const grew =
|
const before = this.grid().rows;
|
||||||
Math.ceil((colors.length + 1) / PALETTE_COLUMNS) !==
|
|
||||||
Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS);
|
|
||||||
this.colors = colors;
|
this.colors = colors;
|
||||||
this.picked = picked;
|
this.picked = picked;
|
||||||
if (grew) (this.parent as Widget | null)?.invalidate();
|
if (this.grid().rows !== before) this.onReflow();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
override measure(): Size {
|
||||||
const rows = Math.max(1, Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS));
|
const { columns, cell, rows } = this.grid();
|
||||||
return { w: PALETTE_COLUMNS * (CELL + GAP), h: rows * (CELL + GAP) };
|
return { w: columns * (cell + GAP) - GAP, h: rows * (cell + GAP) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new width can mean another number of rows, and so another height than the one given. */
|
||||||
|
override layout(): void {
|
||||||
|
if (this.measure().h !== this.h) queueMicrotask(() => this.onReflow());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a swatch's centre sits on screen – the check scripts click it. */
|
/** Where a swatch's centre sits on screen – the check scripts click it. */
|
||||||
swatchGlobal(index: number): { x: number; y: number } {
|
swatchGlobal(index: number): { x: number; y: number } {
|
||||||
const p = this.getGlobalPosition();
|
const p = this.getGlobalPosition();
|
||||||
|
const { cell } = this.grid();
|
||||||
const at = this.cellAt(index);
|
const at = this.cellAt(index);
|
||||||
return { x: p.x + at.x + CELL / 2, y: p.y + at.y + CELL / 2 };
|
return { x: p.x + at.x + cell / 2, y: p.y + at.y + cell / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private cellAt(index: number): { x: number; y: number } {
|
private cellAt(index: number): { x: number; y: number } {
|
||||||
|
const { columns, cell } = this.grid();
|
||||||
return {
|
return {
|
||||||
x: (index % PALETTE_COLUMNS) * (CELL + GAP),
|
x: (index % columns) * (cell + GAP),
|
||||||
y: Math.floor(index / PALETTE_COLUMNS) * (CELL + GAP),
|
y: Math.floor(index / columns) * (cell + GAP),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
const g = this.swatches.clear();
|
const g = this.swatches.clear();
|
||||||
|
const { cell } = this.grid();
|
||||||
for (let index = 0; index <= this.colors.length; index++) {
|
for (let index = 0; index <= this.colors.length; index++) {
|
||||||
const { x, y } = this.cellAt(index);
|
const { x, y } = this.cellAt(index);
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
g.rect(x, y, CELL, CELL).fill(0x9aa0aa);
|
g.rect(x, y, cell, cell).fill(0x9aa0aa);
|
||||||
g.rect(x, y, CELL / 2, CELL / 2)
|
g.rect(x, y, cell / 2, cell / 2)
|
||||||
.rect(x + CELL / 2, y + CELL / 2, CELL / 2, CELL / 2)
|
.rect(x + cell / 2, y + cell / 2, cell / 2, cell / 2)
|
||||||
.fill(0x5a5f69);
|
.fill(0x5a5f69);
|
||||||
} else g.rect(x, y, CELL, CELL).fill(this.colors[index - 1] ?? 0);
|
} else g.rect(x, y, cell, cell).fill(this.colors[index - 1] ?? 0);
|
||||||
g.rect(x + 0.5, y + 0.5, CELL - 1, CELL - 1).stroke({ width: 1, color: Theme.colors.border });
|
g.rect(x + 0.5, y + 0.5, cell - 1, cell - 1).stroke({ width: 1, color: Theme.colors.border });
|
||||||
}
|
}
|
||||||
if (this.picked >= 0 && this.picked <= this.colors.length) {
|
if (this.picked >= 0 && this.picked <= this.colors.length) {
|
||||||
const { x, y } = this.cellAt(this.picked);
|
const { x, y } = this.cellAt(this.picked);
|
||||||
g.rect(x - 1, y - 1, CELL + 2, CELL + 2).stroke({ width: 2, color: Theme.colors.accent });
|
g.rect(x - 1, y - 1, cell + 2, cell + 2).stroke({ width: 2, color: Theme.colors.accent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,8 +40,8 @@ import {
|
|||||||
moveFrame,
|
moveFrame,
|
||||||
moveLayer,
|
moveLayer,
|
||||||
removeFrame,
|
removeFrame,
|
||||||
removeLastPaletteColor,
|
|
||||||
removeLayer,
|
removeLayer,
|
||||||
|
removePaletteColor,
|
||||||
renameLayer,
|
renameLayer,
|
||||||
setLayerHidden,
|
setLayerHidden,
|
||||||
setLayerOpacity,
|
setLayerOpacity,
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from './pixelActions';
|
} from './pixelActions';
|
||||||
import { PixelPreview } from './PixelPreview';
|
import { PixelPreview } from './PixelPreview';
|
||||||
import { PixelThumbs } from './PixelThumbs';
|
import { PixelThumbs } from './PixelThumbs';
|
||||||
|
import { pixelToolOptions } from './toolOptions';
|
||||||
|
|
||||||
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
|
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
|
||||||
const TOOLS_PER_ROW = 4;
|
const TOOLS_PER_ROW = 4;
|
||||||
@@ -64,10 +65,13 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
|
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
|
||||||
readonly filledBox = new Checkbox(false);
|
readonly filledBox = new Checkbox(false);
|
||||||
readonly contiguousBox = new Checkbox(true);
|
readonly contiguousBox = new Checkbox(true);
|
||||||
|
private readonly toolOptions: HStack;
|
||||||
|
private readonly filledOption: HStack;
|
||||||
|
private readonly contiguousOption: HStack;
|
||||||
readonly palette = new PaletteGrid();
|
readonly palette = new PaletteGrid();
|
||||||
readonly colorField = new ColorField(0);
|
readonly colorField = new ColorField(0);
|
||||||
readonly addColorButton = new Button('+ Colour');
|
readonly addColorButton = new Button('Add');
|
||||||
readonly removeColorButton = new Button('Remove last');
|
readonly removeColorButton = new Button('Remove');
|
||||||
readonly createPaletteButton = new Button('Create palette');
|
readonly createPaletteButton = new Button('Create palette');
|
||||||
private readonly paletteNote = new Label('', { style: 'muted' });
|
private readonly paletteNote = new Label('', { style: 'muted' });
|
||||||
/** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */
|
/** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */
|
||||||
@@ -104,25 +108,28 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
c.add(tools);
|
c.add(tools);
|
||||||
}
|
}
|
||||||
this.brushButton.onClick = () => this.openBrushMenu();
|
this.brushButton.onClick = () => this.openBrushMenu();
|
||||||
c.add(row(this.brushButton));
|
this.filledBox.onChange = (on) => {
|
||||||
this.filledBox.onChange = (on) => (this.canvas.filled = on);
|
this.canvas.filled = on;
|
||||||
|
this.showToolOptions();
|
||||||
|
};
|
||||||
this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on);
|
this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on);
|
||||||
c.add(
|
this.filledOption = row(this.filledBox, new Label('Filled shapes'));
|
||||||
row(
|
this.contiguousOption = row(this.contiguousBox, new Label('Contiguous'));
|
||||||
this.filledBox,
|
// One row for all of them: it keeps its height while a tool has none, so nothing below jumps.
|
||||||
new Label('Filled shapes'),
|
// The brush goes last, so the box that hides it stays under the pointer.
|
||||||
new Spacer(),
|
this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton);
|
||||||
this.contiguousBox,
|
c.add(this.toolOptions);
|
||||||
new Label('Contiguous'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
c.add(heading('Colours'));
|
c.add(heading('Colours'));
|
||||||
this.palette.onPick = (index) => this.canvas.setColor(index);
|
this.palette.onPick = (index) => this.canvas.setColor(index);
|
||||||
|
this.palette.onReflow = () => this.relayoutContent();
|
||||||
c.add(this.palette);
|
c.add(this.palette);
|
||||||
this.colorField.onInput = (color) => this.previewColor(color);
|
this.colorField.onInput = (color) => this.previewColor(color);
|
||||||
this.colorField.onChange = (color) => this.commitColor(color);
|
this.colorField.onChange = (color) => this.commitColor(color);
|
||||||
c.add(row(this.colorField, this.addColorButton, this.removeColorButton));
|
// The field takes what the two buttons leave, so the row ends inside the panel.
|
||||||
|
const colorRow = row(this.colorField, this.addColorButton, this.removeColorButton);
|
||||||
|
this.colorField.flex = 1;
|
||||||
|
c.add(colorRow);
|
||||||
this.addColorButton.onClick = () => this.addColor();
|
this.addColorButton.onClick = () => this.addColor();
|
||||||
this.removeColorButton.onClick = () => this.removeColor();
|
this.removeColorButton.onClick = () => this.removeColor();
|
||||||
this.createPaletteButton.onClick = () => this.createPalette();
|
this.createPaletteButton.onClick = () => this.createPalette();
|
||||||
@@ -217,9 +224,24 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
this.ensureVisible(top, widget.h);
|
this.ensureVisible(top, widget.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the column's controls may reach on screen, inside its padding. */
|
||||||
|
contentSpan(): { left: number; right: number } {
|
||||||
|
const left = this.column.getGlobalPosition().x + this.column.padding;
|
||||||
|
return { left, right: left + this.column.w - 2 * this.column.padding };
|
||||||
|
}
|
||||||
|
|
||||||
setTool(tool: PixelTool): void {
|
setTool(tool: PixelTool): void {
|
||||||
this.canvas.setTool(tool);
|
this.canvas.setTool(tool);
|
||||||
for (const [t, button] of this.toolButtons) button.setToggled(t === tool);
|
for (const [t, button] of this.toolButtons) button.setToggled(t === tool);
|
||||||
|
this.showToolOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private showToolOptions(): void {
|
||||||
|
const shown = pixelToolOptions(this.canvas.tool, this.canvas.filled);
|
||||||
|
this.brushButton.visible = shown.brush;
|
||||||
|
this.filledOption.visible = shown.filled;
|
||||||
|
this.contiguousOption.visible = shown.contiguous;
|
||||||
|
this.toolOptions.relayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
setBrush(size: number): void {
|
setBrush(size: number): void {
|
||||||
@@ -280,9 +302,10 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
if (index > 0) this.canvas.setColor(index);
|
if (index > 0) this.canvas.setColor(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The picked colour goes; the one that moves into its place is picked next. */
|
||||||
private removeColor(): void {
|
private removeColor(): void {
|
||||||
const palette = this.paletteAsset();
|
const palette = this.paletteAsset();
|
||||||
if (!palette || !removeLastPaletteColor(this.state, palette)) return;
|
if (!palette || !removePaletteColor(this.state, palette, this.canvas.color)) return;
|
||||||
if (this.canvas.color > palette.colors.length) this.canvas.setColor(palette.colors.length);
|
if (this.canvas.color > palette.colors.length) this.canvas.setColor(palette.colors.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +393,7 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
const editable = palette !== null && current !== null;
|
const editable = palette !== null && current !== null;
|
||||||
this.colorField.setEnabled(editable);
|
this.colorField.setEnabled(editable);
|
||||||
this.addColorButton.setEnabled(palette !== null);
|
this.addColorButton.setEnabled(palette !== null);
|
||||||
this.removeColorButton.setEnabled(palette !== null && palette.colors.length > 0);
|
this.removeColorButton.setEnabled(editable);
|
||||||
this.paletteNote.setText(
|
this.paletteNote.setText(
|
||||||
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours',
|
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
* pure state edits; resizing and merging also need the pixels, which only this browser holds, so
|
* pure state edits; resizing and merging also need the pixels, which only this browser holds, so
|
||||||
* their new cels are worked out here and handed to the edit by name.
|
* their new cels are worked out here and handed to the edit by name.
|
||||||
*/
|
*/
|
||||||
|
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands';
|
import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands';
|
||||||
import { newId } from '../../core/ids';
|
import { newId } from '../../core/ids';
|
||||||
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
|
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
|
||||||
import { celFilesOf, celOf, PALETTE_MAX } from '../../core/model/pixelart';
|
import { celFilesOf, celOf, isPixelArt, PALETTE_MAX, paletteAssetOf } from '../../core/model/pixelart';
|
||||||
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
|
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
|
||||||
import { mergeCels, type ResizeAnchor, resizeCel } from '../../core/model/pixelDraw';
|
import { mergeCels, type ResizeAnchor, resizeCel, withoutColorIndex } from '../../core/model/pixelDraw';
|
||||||
import {
|
import {
|
||||||
type PixelArtState,
|
type PixelArtState,
|
||||||
withCanvasSize,
|
withCanvasSize,
|
||||||
|
withCelsReplaced,
|
||||||
withFrameAdded,
|
withFrameAdded,
|
||||||
withFrameMoved,
|
withFrameMoved,
|
||||||
withFrameRemoved,
|
withFrameRemoved,
|
||||||
@@ -179,10 +181,31 @@ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only the last colour can go: every pixel names its colour by index, and taking one out of the
|
* Colour `index` out of the palette (#201). Every pixel names its colour by index, so every picture
|
||||||
* middle would shift the colour of every pixel above it in every picture painted with the palette.
|
* painted with the palette is rewritten with it: that colour's pixels turn transparent, the ones
|
||||||
|
* after it move down one. Palette and pictures change in one undo step.
|
||||||
*/
|
*/
|
||||||
export function removeLastPaletteColor(state: EditorState, palette: PaletteAsset): boolean {
|
export function removePaletteColor(state: EditorState, palette: PaletteAsset, index: number): boolean {
|
||||||
if (palette.colors.length === 0) return false;
|
if (index < 1 || index > palette.colors.length) return false;
|
||||||
return state.execute(new SetPaletteColorsCommand(palette.id, 'Remove colour', palette.colors.slice(0, -1)));
|
const users = state.project.assets.filter(
|
||||||
|
(asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(state.project, asset) === palette,
|
||||||
|
);
|
||||||
|
const rewrites: EditPixelArtCommand[] = [];
|
||||||
|
for (const asset of users) {
|
||||||
|
const cels = loadedCels(asset);
|
||||||
|
if (!cels) {
|
||||||
|
state.setNotice(STILL_LOADING);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const replaced: Record<string, string> = {};
|
||||||
|
for (const [file, cel] of cels) {
|
||||||
|
const next = withoutColorIndex(cel, index);
|
||||||
|
if (next) replaced[file] = pixelCels.put(next);
|
||||||
|
}
|
||||||
|
if (Object.keys(replaced).length > 0)
|
||||||
|
rewrites.push(new EditPixelArtCommand(asset.id, 'Remove colour', (s) => withCelsReplaced(s, replaced)));
|
||||||
|
}
|
||||||
|
const colors = palette.colors.filter((_, i) => i !== index - 1);
|
||||||
|
const recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors);
|
||||||
|
return state.execute(oneStep('Remove colour', [recolor, ...rewrites]));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PixelTool } from './PixelArtCanvas';
|
||||||
|
|
||||||
|
export interface PixelToolOptions {
|
||||||
|
brush: boolean;
|
||||||
|
filled: boolean;
|
||||||
|
contiguous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options that change what a tool paints (#201) – the side panel shows only these. A filled
|
||||||
|
* rectangle or ellipse is filled pixel by pixel, so the brush size means nothing to it.
|
||||||
|
*/
|
||||||
|
export function pixelToolOptions(tool: PixelTool, filled: boolean): PixelToolOptions {
|
||||||
|
const shape = tool === 'rect' || tool === 'ellipse';
|
||||||
|
return {
|
||||||
|
brush: tool === 'pencil' || tool === 'eraser' || tool === 'line' || (shape && !filled),
|
||||||
|
filled: shape,
|
||||||
|
contiguous: tool === 'fill',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* The pixel art side panel after #201: the options a tool shows, the palette across the whole
|
||||||
|
* width, and taking out the picked colour of a palette while every picture painted with it keeps
|
||||||
|
* its colours.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { EditPixelArtCommand } from '../../src/core/commands/pixelArtCommands';
|
||||||
|
import type { PaletteAsset, PixelArtAsset } from '../../src/core/model/Asset';
|
||||||
|
import { createEmptyProject } from '../../src/core/model/Project';
|
||||||
|
import { celOf, PALETTE_KEY } from '../../src/core/model/pixelart';
|
||||||
|
import type { CelPixels } from '../../src/core/model/pixelCel';
|
||||||
|
import { withoutColorIndex } from '../../src/core/model/pixelDraw';
|
||||||
|
import { withCels, withCelsReplaced } from '../../src/core/model/pixelEdit';
|
||||||
|
import { paletteLayout } from '../../src/editor/pixelart/PaletteGrid';
|
||||||
|
import { removePaletteColor } from '../../src/editor/pixelart/pixelActions';
|
||||||
|
import { pixelToolOptions } from '../../src/editor/pixelart/toolOptions';
|
||||||
|
import { EditorState } from '../../src/editor/state';
|
||||||
|
import { pixelCels } from '../../src/viewport/PixelCels';
|
||||||
|
|
||||||
|
function cel(data: number[]): CelPixels {
|
||||||
|
return { width: data.length, height: 1, data: Uint8Array.from(data) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tool options', () => {
|
||||||
|
it('shows each option only with the tools it changes', () => {
|
||||||
|
expect(pixelToolOptions('pencil', false)).toEqual({ brush: true, filled: false, contiguous: false });
|
||||||
|
expect(pixelToolOptions('eraser', false)).toEqual({ brush: true, filled: false, contiguous: false });
|
||||||
|
expect(pixelToolOptions('line', true)).toEqual({ brush: true, filled: false, contiguous: false });
|
||||||
|
expect(pixelToolOptions('fill', false)).toEqual({ brush: false, filled: false, contiguous: true });
|
||||||
|
expect(pixelToolOptions('picker', false)).toEqual({ brush: false, filled: false, contiguous: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the brush once a shape is filled', () => {
|
||||||
|
for (const tool of ['rect', 'ellipse'] as const) {
|
||||||
|
expect(pixelToolOptions(tool, false)).toEqual({ brush: true, filled: true, contiguous: false });
|
||||||
|
expect(pixelToolOptions(tool, true)).toEqual({ brush: false, filled: true, contiguous: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('palette layout', () => {
|
||||||
|
it('fills the width with as many swatches as fit, flush to the right', () => {
|
||||||
|
// 260 px hold 11 swatches of 20 with 2 between; what is left widens them to 21.
|
||||||
|
expect(paletteLayout(260, 17)).toEqual({ columns: 11, cell: 21, rows: 2 });
|
||||||
|
expect(paletteLayout(176, 17)).toEqual({ columns: 8, cell: 20, rows: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out eight to a row before the width is known, and one row at least', () => {
|
||||||
|
expect(paletteLayout(0, 17)).toEqual({ columns: 8, cell: 20, rows: 3 });
|
||||||
|
expect(paletteLayout(100, 1)).toEqual({ columns: 4, cell: 23, rows: 1 });
|
||||||
|
expect(paletteLayout(10, 3)).toEqual({ columns: 1, cell: 20, rows: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a colour out of the middle', () => {
|
||||||
|
it('turns its pixels transparent and moves the later ones down', () => {
|
||||||
|
expect([...withoutColorIndex(cel([0, 1, 2, 3, 4]), 2)!.data]).toEqual([0, 1, 0, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a cel alone that uses no colour from there on', () => {
|
||||||
|
expect(withoutColorIndex(cel([0, 1, 1]), 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps only the cels it is given', () => {
|
||||||
|
const state = {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
layers: [
|
||||||
|
{ id: 'a', name: 'A', opacity: 1 },
|
||||||
|
{ id: 'b', name: 'B', opacity: 1 },
|
||||||
|
],
|
||||||
|
frames: [{ id: 'f', cels: { a: 'one.px', b: 'two.px' } }],
|
||||||
|
};
|
||||||
|
const next = withCelsReplaced(state, { 'one.px': 'three.px', 'two.px': '' });
|
||||||
|
expect(next.frames[0]!.cels).toEqual({ a: 'three.px' });
|
||||||
|
expect(withCelsReplaced(state, {}).frames[0]!.cels).toEqual({ a: 'one.px', b: 'two.px' });
|
||||||
|
expect(state.frames[0]!.cels).toEqual({ a: 'one.px', b: 'two.px' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removing the picked colour', () => {
|
||||||
|
function setup() {
|
||||||
|
const state = new EditorState();
|
||||||
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||||
|
const create = <T>(type: 'pixelart' | 'palette', name: string): T => {
|
||||||
|
const command = new CreateAssetCommand(type, name);
|
||||||
|
expect(state.execute(command)).toBe(true);
|
||||||
|
return command.asset as T;
|
||||||
|
};
|
||||||
|
const palette = create<PaletteAsset>('palette', 'Colours');
|
||||||
|
const paint = (name: string, pixels: number[]) => {
|
||||||
|
const art = create<PixelArtAsset>('pixelart', name);
|
||||||
|
expect(state.execute(new SetAssetPropertyCommand(art.id, PALETTE_KEY, palette.id))).toBe(true);
|
||||||
|
const change = {
|
||||||
|
frameId: art.frames[0]!.id,
|
||||||
|
layerId: art.layers[0]!.id,
|
||||||
|
file: pixelCels.put(cel(pixels)),
|
||||||
|
};
|
||||||
|
expect(state.execute(new EditPixelArtCommand(art.id, 'Paint', (s) => withCels(s, [change])))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return art;
|
||||||
|
};
|
||||||
|
const pixelsOf = (art: PixelArtAsset) => {
|
||||||
|
const file = celOf(art.frames[0]!, art.layers[0]!.id);
|
||||||
|
return file ? [...pixelCels.read(file)!.data] : null;
|
||||||
|
};
|
||||||
|
return { state, palette, paint, pixelsOf };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rewrites every picture of the palette so each keeps its colours, in one undo step', () => {
|
||||||
|
const { state, palette, paint, pixelsOf } = setup();
|
||||||
|
const colors = [...palette.colors];
|
||||||
|
const first = paint('First', [1, 2, 3, 0]);
|
||||||
|
const second = paint('Second', [3, 3, 1, 2]);
|
||||||
|
expect(removePaletteColor(state, palette, 2)).toBe(true);
|
||||||
|
expect(palette.colors).toEqual(colors.filter((_, i) => i !== 1));
|
||||||
|
expect(pixelsOf(first)).toEqual([1, 0, 2, 0]);
|
||||||
|
expect(pixelsOf(second)).toEqual([2, 2, 1, 0]);
|
||||||
|
// Each remaining pixel shows the colour it showed before.
|
||||||
|
expect(palette.colors[2 - 1]).toBe(colors[3 - 1]);
|
||||||
|
|
||||||
|
state.undo();
|
||||||
|
expect(palette.colors).toEqual(colors);
|
||||||
|
expect(pixelsOf(first)).toEqual([1, 2, 3, 0]);
|
||||||
|
expect(pixelsOf(second)).toEqual([3, 3, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empties a cel that held only that colour', () => {
|
||||||
|
const { state, palette, paint, pixelsOf } = setup();
|
||||||
|
const art = paint('Only', [4, 4]);
|
||||||
|
expect(removePaletteColor(state, palette, 4)).toBe(true);
|
||||||
|
expect(pixelsOf(art)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses transparent and an index past the end', () => {
|
||||||
|
const { state, palette } = setup();
|
||||||
|
expect(removePaletteColor(state, palette, 0)).toBe(false);
|
||||||
|
expect(removePaletteColor(state, palette, palette.colors.length + 1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+107
-1
@@ -205,9 +205,11 @@ export async function pixelArt(t) {
|
|||||||
JSON.stringify({ rectCount, lineCount }),
|
JSON.stringify({ rectCount, lineCount }),
|
||||||
);
|
);
|
||||||
await undo();
|
await undo();
|
||||||
|
// Filled shapes stands beside the shape tools only.
|
||||||
|
await press_('ellipse');
|
||||||
const filledBox = await control('filled');
|
const filledBox = await control('filled');
|
||||||
await click(filledBox.x, filledBox.y);
|
await click(filledBox.x, filledBox.y);
|
||||||
await press_('ellipse');
|
await settle();
|
||||||
await paint([20, 20], [27, 27]);
|
await paint([20, 20], [27, 27]);
|
||||||
data = await cel(id, 0, 1);
|
data = await cel(id, 0, 1);
|
||||||
check(
|
check(
|
||||||
@@ -526,6 +528,110 @@ export async function pixelArt(t) {
|
|||||||
JSON.stringify({ recolouredColor, recoloured, restored }),
|
JSON.stringify({ recolouredColor, recoloured, restored }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 48k-ii. Remove takes the picked colour out of the middle (#201): its pixels turn transparent,
|
||||||
|
// the later colours move down one index, and every other pixel keeps the colour it had.
|
||||||
|
const paletteOf = () =>
|
||||||
|
json(
|
||||||
|
`(() => { const p = __gm.state.project; const a = p.assets.find((x) => x.id === ${JSON.stringify(id)});` +
|
||||||
|
` return p.assets.find((x) => x.id === a.props.palette).colors; })()`,
|
||||||
|
);
|
||||||
|
const colorsBefore = await paletteOf();
|
||||||
|
const allCels = async () => {
|
||||||
|
const a = await asset(id);
|
||||||
|
const out = [];
|
||||||
|
for (let f = 0; f < a.frames.length; f++)
|
||||||
|
for (let l = 0; l < a.layers.length; l++) out.push(await cel(id, f, l));
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const celsBefore = await allCels();
|
||||||
|
await swatch(GREEN);
|
||||||
|
const removeButton = await control('removeColor');
|
||||||
|
await click(removeButton.x, removeButton.y);
|
||||||
|
await settle();
|
||||||
|
const colorsAfter = await paletteOf();
|
||||||
|
const celsAfter = await allCels();
|
||||||
|
const shifted = celsBefore.every((before, c) =>
|
||||||
|
before.every((i, p) => celsAfter[c][p] === (i === GREEN ? 0 : i > GREEN ? i - 1 : i)),
|
||||||
|
);
|
||||||
|
// The colour after it moves into its index, so an index that moved down still names its colour.
|
||||||
|
check(
|
||||||
|
'Remove takes the picked colour out: its pixels go transparent, every other pixel keeps its colour',
|
||||||
|
removeButton.text === 'Remove' &&
|
||||||
|
colorsAfter.length === colorsBefore.length - 1 &&
|
||||||
|
colorsAfter[GREEN - 1] === colorsBefore[GREEN] &&
|
||||||
|
celsBefore.some((before) => before.includes(GREEN)) &&
|
||||||
|
shifted,
|
||||||
|
JSON.stringify({
|
||||||
|
text: removeButton.text,
|
||||||
|
before: colorsBefore.length,
|
||||||
|
after: colorsAfter.length,
|
||||||
|
shifted,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await undo();
|
||||||
|
const celsUndone = await allCels();
|
||||||
|
check(
|
||||||
|
'and one undo gives back the colour and every pixel',
|
||||||
|
JSON.stringify(await paletteOf()) === JSON.stringify(colorsBefore) &&
|
||||||
|
JSON.stringify(celsUndone) === JSON.stringify(celsBefore),
|
||||||
|
JSON.stringify({ colors: (await paletteOf()).length }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 48k-iii. The palette runs across the whole width of the panel (#201).
|
||||||
|
await control('palette');
|
||||||
|
const span = await json('__gm.pixelArt.sideSpan()');
|
||||||
|
const firstSwatch = await json(`__gm.pixelArt.swatch(0)`);
|
||||||
|
const cell = (await json(`__gm.pixelArt.swatch(1)`)).x - firstSwatch.x - 2;
|
||||||
|
let perRow = 1;
|
||||||
|
while (perRow < 32 && (await json(`__gm.pixelArt.swatch(${perRow})`)).y === firstSwatch.y) perRow++;
|
||||||
|
const lastInRow = await json(`__gm.pixelArt.swatch(${perRow - 1})`);
|
||||||
|
// Swatches are whole pixels wide, so the row may stop up to a pixel per swatch short.
|
||||||
|
const reach = { left: firstSwatch.x - cell / 2, right: lastInRow.x + cell / 2 };
|
||||||
|
const removeRight = removeButton.x + removeButton.w / 2;
|
||||||
|
check(
|
||||||
|
'the palette fills the width of the panel, more than eight swatches to a row, and Remove ends inside it',
|
||||||
|
perRow > 8 &&
|
||||||
|
Math.abs(reach.left - span.left) <= 1 &&
|
||||||
|
span.right - reach.right <= perRow &&
|
||||||
|
removeRight <= span.right + 0.5,
|
||||||
|
JSON.stringify({ span, reach, perRow, cell, removeRight }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 48k-iv. Each tool shows only the options that change what it paints (#201).
|
||||||
|
const options = async () => ({
|
||||||
|
brush: (await json(`__gm.pixelArt.control('brush')`)) !== null,
|
||||||
|
filled: (await json(`__gm.pixelArt.control('filled')`)) !== null,
|
||||||
|
contiguous: (await json(`__gm.pixelArt.control('contiguous')`)) !== null,
|
||||||
|
});
|
||||||
|
const shownFor = {};
|
||||||
|
for (const tool of ['pencil', 'fill', 'picker', 'rect']) {
|
||||||
|
await press_(tool);
|
||||||
|
shownFor[tool] = await options();
|
||||||
|
}
|
||||||
|
const filledToggle = await control('filled');
|
||||||
|
await click(filledToggle.x, filledToggle.y);
|
||||||
|
await settle();
|
||||||
|
shownFor.filledRect = await options();
|
||||||
|
// The box stays where it was while the brush beside it goes, so the same spot switches it off.
|
||||||
|
await click(filledToggle.x, filledToggle.y);
|
||||||
|
await settle();
|
||||||
|
shownFor.outlinedAgain = (await json(`__gm.pixelArt.control('brush')`)) !== null;
|
||||||
|
const want = {
|
||||||
|
pencil: { brush: true, filled: false, contiguous: false },
|
||||||
|
fill: { brush: false, filled: false, contiguous: true },
|
||||||
|
picker: { brush: false, filled: false, contiguous: false },
|
||||||
|
rect: { brush: true, filled: true, contiguous: false },
|
||||||
|
filledRect: { brush: false, filled: true, contiguous: false },
|
||||||
|
outlinedAgain: true,
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
'brush, Filled shapes and Contiguous show only with the tools they change',
|
||||||
|
JSON.stringify(shownFor) === JSON.stringify(want),
|
||||||
|
JSON.stringify(shownFor),
|
||||||
|
);
|
||||||
|
await press_('pencil');
|
||||||
|
await shot('48-pixel-tools');
|
||||||
|
|
||||||
// 48l. A sprite wears the second frame; the picker offers every frame by name.
|
// 48l. A sprite wears the second frame; the picker offers every frame by name.
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const project = __gm.state.project;
|
const project = __gm.state.project;
|
||||||
|
|||||||
Reference in new issue
Block a user