diff --git a/README.md b/README.md index 90204337..ccde23a2 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/pixelDraw.ts b/src/core/model/pixelDraw.ts index b585c527..b8ee4d09 100644 --- a/src/core/model/pixelDraw.ts +++ b/src/core/model/pixelDraw.ts @@ -156,6 +156,21 @@ export function resizeCel(cel: CelPixels, width: number, height: number, anchor: 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 * lower one. Indexed colours have no in-between colour, so the upper layer's opacity cannot be kept – diff --git a/src/core/model/pixelEdit.ts b/src/core/model/pixelEdit.ts index 7e566ba9..91024999 100644 --- a/src/core/model/pixelEdit.ts +++ b/src/core/model/pixelEdit.ts @@ -207,14 +207,23 @@ export function withCanvasSize( ): PixelArtState { if (!isCanvasSide(width) || !isCanvasSide(height)) 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>, +): PixelArtState { const next = cloneState(state); - next.width = width; - next.height = height; for (const frame of next.frames) for (const [layerId, file] of Object.entries(frame.cels)) { - const resized = cels[file]; - if (resized === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart'); - if (resized) frame.cels[layerId] = resized; + const replaced = cels[file]; + if (replaced === undefined) continue; + if (replaced) frame.cels[layerId] = replaced; else delete frame.cels[layerId]; } return next; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 042d841a..59475595 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -519,14 +519,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E return at ? { ...at, enabled: true, text: '+' } : null; } 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); return { ...centreOf(widget), + w: widget.w, enabled: widget.enabled, 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) => { const side = editor.pixelArtEditor.side; side.reveal(side.palette); diff --git a/src/editor/pixelart/PaletteGrid.ts b/src/editor/pixelart/PaletteGrid.ts index 90cc9249..9a7fcd13 100644 --- a/src/editor/pixelart/PaletteGrid.ts +++ b/src/editor/pixelart/PaletteGrid.ts @@ -3,18 +3,33 @@ import { type Size, Theme, Widget } from '../../ui'; const CELL = 20; 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 – - * transparent, drawn as a checkerboard – and the others follow the palette in order, so a swatch's - * place is its index. A click picks it; the picked one is outlined. + * The colours of a palette as swatches across the whole width (#130, #201). The first swatch is + * index 0 – transparent, drawn as a checkerboard – and the others follow the palette in order, so a + * swatch's place is its index. A click picks it; the picked one is outlined. */ export class PaletteGrid extends Widget { private readonly swatches = new Graphics(); private colors: readonly number[] = []; private picked = 1; onPick: (index: number) => void = () => {}; + /** Told when the swatches need another number of rows, so the column around lays out again. */ + onReflow: () => void = () => {}; constructor() { super(); @@ -22,58 +37,69 @@ export class PaletteGrid extends Widget { this.cursor = 'pointer'; this.on('pointerdown', (e: FederatedPointerEvent) => { const local = this.toLocal(e.global); - const column = Math.floor(local.x / (CELL + GAP)); - const row = Math.floor(local.y / (CELL + GAP)); - const index = row * PALETTE_COLUMNS + column; - if (column < PALETTE_COLUMNS && index <= this.colors.length) this.onPick(index); + const { columns, cell } = this.grid(); + const column = Math.floor(local.x / (cell + GAP)); + const row = Math.floor(local.y / (cell + GAP)); + const index = row * columns + column; + if (column < columns && index <= this.colors.length) this.onPick(index); e.stopPropagation(); }); } + private grid(): PaletteLayout { + return paletteLayout(this.w, this.colors.length + 1); + } + setColors(colors: readonly number[], picked: number): void { - const grew = - Math.ceil((colors.length + 1) / PALETTE_COLUMNS) !== - Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS); + const before = this.grid().rows; this.colors = colors; this.picked = picked; - if (grew) (this.parent as Widget | null)?.invalidate(); + if (this.grid().rows !== before) this.onReflow(); this.invalidate(); } override measure(): Size { - const rows = Math.max(1, Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS)); - return { w: PALETTE_COLUMNS * (CELL + GAP), h: rows * (CELL + GAP) }; + const { columns, cell, rows } = this.grid(); + 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. */ swatchGlobal(index: number): { x: number; y: number } { const p = this.getGlobalPosition(); + const { cell } = this.grid(); 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 } { + const { columns, cell } = this.grid(); return { - x: (index % PALETTE_COLUMNS) * (CELL + GAP), - y: Math.floor(index / PALETTE_COLUMNS) * (CELL + GAP), + x: (index % columns) * (cell + GAP), + y: Math.floor(index / columns) * (cell + GAP), }; } override redraw(): void { const g = this.swatches.clear(); + const { cell } = this.grid(); for (let index = 0; index <= this.colors.length; index++) { const { x, y } = this.cellAt(index); if (index === 0) { - g.rect(x, y, CELL, CELL).fill(0x9aa0aa); - g.rect(x, y, CELL / 2, CELL / 2) - .rect(x + CELL / 2, y + CELL / 2, CELL / 2, CELL / 2) + g.rect(x, y, cell, cell).fill(0x9aa0aa); + g.rect(x, y, cell / 2, cell / 2) + .rect(x + cell / 2, y + cell / 2, cell / 2, cell / 2) .fill(0x5a5f69); - } 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 }); + } 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 }); } if (this.picked >= 0 && this.picked <= this.colors.length) { 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 }); } } } diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index 58d52690..d1407d07 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -40,8 +40,8 @@ import { moveFrame, moveLayer, removeFrame, - removeLastPaletteColor, removeLayer, + removePaletteColor, renameLayer, setLayerHidden, setLayerOpacity, @@ -49,6 +49,7 @@ import { } from './pixelActions'; import { PixelPreview } from './PixelPreview'; import { PixelThumbs } from './PixelThumbs'; +import { pixelToolOptions } from './toolOptions'; /** Four tools to a row, so the column stays narrow enough for the canvas beside it. */ const TOOLS_PER_ROW = 4; @@ -64,10 +65,13 @@ export class PixelArtSide extends ScrollContainer { readonly brushButton = new Dropdown(brushLabel(1), { width: 120 }); readonly filledBox = new Checkbox(false); readonly contiguousBox = new Checkbox(true); + private readonly toolOptions: HStack; + private readonly filledOption: HStack; + private readonly contiguousOption: HStack; readonly palette = new PaletteGrid(); readonly colorField = new ColorField(0); - readonly addColorButton = new Button('+ Colour'); - readonly removeColorButton = new Button('Remove last'); + readonly addColorButton = new Button('Add'); + readonly removeColorButton = new Button('Remove'); readonly createPaletteButton = new Button('Create palette'); 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. */ @@ -104,25 +108,28 @@ export class PixelArtSide extends ScrollContainer { c.add(tools); } this.brushButton.onClick = () => this.openBrushMenu(); - c.add(row(this.brushButton)); - this.filledBox.onChange = (on) => (this.canvas.filled = on); + this.filledBox.onChange = (on) => { + this.canvas.filled = on; + this.showToolOptions(); + }; this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on); - c.add( - row( - this.filledBox, - new Label('Filled shapes'), - new Spacer(), - this.contiguousBox, - new Label('Contiguous'), - ), - ); + this.filledOption = row(this.filledBox, new Label('Filled shapes')); + this.contiguousOption = row(this.contiguousBox, new Label('Contiguous')); + // One row for all of them: it keeps its height while a tool has none, so nothing below jumps. + // The brush goes last, so the box that hides it stays under the pointer. + this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton); + c.add(this.toolOptions); c.add(heading('Colours')); this.palette.onPick = (index) => this.canvas.setColor(index); + this.palette.onReflow = () => this.relayoutContent(); c.add(this.palette); this.colorField.onInput = (color) => this.previewColor(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.removeColorButton.onClick = () => this.removeColor(); this.createPaletteButton.onClick = () => this.createPalette(); @@ -217,9 +224,24 @@ export class PixelArtSide extends ScrollContainer { 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 { this.canvas.setTool(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 { @@ -280,9 +302,10 @@ export class PixelArtSide extends ScrollContainer { if (index > 0) this.canvas.setColor(index); } + /** The picked colour goes; the one that moves into its place is picked next. */ private removeColor(): void { 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); } @@ -370,7 +393,7 @@ export class PixelArtSide extends ScrollContainer { const editable = palette !== null && current !== null; this.colorField.setEnabled(editable); this.addColorButton.setEnabled(palette !== null); - this.removeColorButton.setEnabled(palette !== null && palette.colors.length > 0); + this.removeColorButton.setEnabled(editable); this.paletteNote.setText( palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours', ); diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index 86d98151..99f293d4 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -3,15 +3,17 @@ * 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. */ +import { oneStep } from '../../core/commands/CompoundCommand'; import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands'; import { newId } from '../../core/ids'; 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 { mergeCels, type ResizeAnchor, resizeCel } from '../../core/model/pixelDraw'; +import { mergeCels, type ResizeAnchor, resizeCel, withoutColorIndex } from '../../core/model/pixelDraw'; import { type PixelArtState, withCanvasSize, + withCelsReplaced, withFrameAdded, withFrameMoved, 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 - * middle would shift the colour of every pixel above it in every picture painted with the palette. + * Colour `index` out of the palette (#201). Every pixel names its colour by index, so every picture + * 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 { - if (palette.colors.length === 0) return false; - return state.execute(new SetPaletteColorsCommand(palette.id, 'Remove colour', palette.colors.slice(0, -1))); +export function removePaletteColor(state: EditorState, palette: PaletteAsset, index: number): boolean { + if (index < 1 || index > palette.colors.length) return false; + 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 = {}; + 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])); } diff --git a/src/editor/pixelart/toolOptions.ts b/src/editor/pixelart/toolOptions.ts new file mode 100644 index 00000000..28f6df44 --- /dev/null +++ b/src/editor/pixelart/toolOptions.ts @@ -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', + }; +} diff --git a/tests/editor/pixel-palette.test.ts b/tests/editor/pixel-palette.test.ts new file mode 100644 index 00000000..60ff45fa --- /dev/null +++ b/tests/editor/pixel-palette.test.ts @@ -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 = (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('palette', 'Colours'); + const paint = (name: string, pixels: number[]) => { + const art = create('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); + }); +}); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 8afec830..19a1aaed 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -205,9 +205,11 @@ export async function pixelArt(t) { JSON.stringify({ rectCount, lineCount }), ); await undo(); + // Filled shapes stands beside the shape tools only. + await press_('ellipse'); const filledBox = await control('filled'); await click(filledBox.x, filledBox.y); - await press_('ellipse'); + await settle(); await paint([20, 20], [27, 27]); data = await cel(id, 0, 1); check( @@ -526,6 +528,110 @@ export async function pixelArt(t) { 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. await mutate(` const project = __gm.state.project;