Pixel art can be drawn in its own tab with layers and frames (#130)
Textures are uploaded files named by their content, so they cannot be edited in place. Pixel art is therefore its own asset type, like vector art. Its pixels live in the project and the tab rasters them into a texture that sprites, tiles and animations can use. - New asset types Pixel art and Palette. The first pixel art of a project gets a palette with the 16 PICO-8 colours in the same undo step. Every pixel is a palette index, 0 is transparent. - Each layer on each frame is its own file data/pixels/<sha256>.px. The project only holds the names, so undo swaps names. The browser computes the hash and uploads new cels before the project is saved. - New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201 and 422 for broken data. data/pixels/ is git-ignored. - Tools: pencil, eraser, fill (contiguous or all pixels of a colour), eyedropper, line, rectangle and ellipse (outline or filled), brush size 1 to 8. - Layers can be added, deleted, renamed, reordered, hidden, faded and merged down. Frames can be added, duplicated, deleted and reordered, with onion skin and a small playing preview. - Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and Resize... with a 3x3 anchor. - All frames sit side by side in one image. Each frame is a region like a cut texture frame, so texture rows offer "Asset / Frame 2" and the context menu has Create animation from frames. Decisions: - Merging down drops the opacity of the upper layer. Indexed colours know no blends. - Only the last palette colour can be removed. Otherwise the indices of every image using the palette would shift. - Undo stores the whole layer per stroke instead of pixel diffs. - Alt+click picks a colour. It is a mouse gesture, not a shortcut. Follow-up issues: #197 import from texture, #198 selection and flipping, #199 PNG export, #200 more tools. Tests: - ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in tests/core/pixel-art.test.ts, 4 from the icon tables. - e2e module 48 fresh: ALL PASSED, 29 checks, console empty. - Counter-probes bite: five on the unit tests, upload order (2 tests fail) and cels not uploaded in the browser (1 check fails). - Check total 1259 in README is computed (1232 measured in CI + 27). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c8a20fcac
commit
66d6aafbb4
43 files changed
+4018
-24
No files matched your search
+11
-1
@@ -10,6 +10,8 @@ import { fontLibrary } from '../viewport/FontLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { projectPictureFactory } from '../viewport/generatedPictures';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { installPixelArtPictures } from '../viewport/pixelArtPicture';
|
||||
import { pixelCels } from '../viewport/PixelCels';
|
||||
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
|
||||
import { LoginScreen } from './auth/LoginScreen';
|
||||
import { installLoginHook } from './auth/loginHook';
|
||||
@@ -17,6 +19,7 @@ import { consoleLog } from './console/log';
|
||||
import { installDebugHook } from './debugHook';
|
||||
import { Editor } from './Editor';
|
||||
import { startPlugins } from './plugins/startPlugins';
|
||||
import { CelUploadStore } from './pixelart/celUploadStore';
|
||||
import { EditorState } from './state';
|
||||
|
||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
@@ -28,6 +31,7 @@ const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
const TEXTURE_BASE = '/game-maker/data/textures';
|
||||
const FONT_BASE = '/game-maker/data/fonts';
|
||||
const SOUND_BASE = '/game-maker/data/sounds';
|
||||
const PIXEL_BASE = '/game-maker/data/pixels';
|
||||
|
||||
/**
|
||||
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
|
||||
@@ -71,6 +75,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
textureLibrary.configure(TEXTURE_BASE);
|
||||
fontLibrary.configure(FONT_BASE);
|
||||
soundLibrary.configure(SOUND_BASE);
|
||||
pixelCels.configure(PIXEL_BASE);
|
||||
// An image or a font arrives long after the click that asked for it, and the widget waiting for
|
||||
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
|
||||
// for the page, not per session – the files they hold are shared by every account anyway.
|
||||
@@ -79,6 +84,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
// Vector art draws its own picture and needs the renderer to do it (#129). Registered once for
|
||||
// the page like the libraries above: which project it is asked about comes from the factory below.
|
||||
installVectorArtPictures(app.renderer);
|
||||
installPixelArtPictures();
|
||||
|
||||
const openSession = async (): Promise<void> => {
|
||||
const user = await signedInUser(app, root, auth);
|
||||
@@ -93,7 +99,11 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
new CelUploadStore(
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
pixelCels,
|
||||
new HttpFileStore(API_ENDPOINT, 'pixels'),
|
||||
),
|
||||
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
|
||||
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||
|
||||
@@ -54,6 +54,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
import type { PlayFrom } from './settings';
|
||||
import type { EditorState } from './state';
|
||||
import { PixelArtEditor } from './pixelart/PixelArtEditor';
|
||||
import { VectorArtEditor } from './vectorart/VectorArtEditor';
|
||||
import { TextureEditor } from './texture/TextureEditor';
|
||||
import { installFileImport } from './fileImport';
|
||||
@@ -69,6 +70,7 @@ export type CentreTab =
|
||||
| 'game'
|
||||
| 'texture'
|
||||
| 'vectorart'
|
||||
| 'pixelart'
|
||||
| 'animation'
|
||||
| 'tile'
|
||||
| 'container'
|
||||
@@ -108,6 +110,7 @@ const PANE_ORDER = [
|
||||
'particles',
|
||||
'texture',
|
||||
'vectorart',
|
||||
'pixelart',
|
||||
'animation',
|
||||
'tile',
|
||||
'container',
|
||||
@@ -125,6 +128,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
particles: 'Particles',
|
||||
texture: 'Texture',
|
||||
vectorart: 'Vector art',
|
||||
pixelart: 'Pixel art',
|
||||
animation: 'Animation',
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
@@ -169,6 +173,7 @@ export class Editor implements PlayHost {
|
||||
readonly particlesEditor: ParticlesEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly vectorArtEditor: VectorArtEditor;
|
||||
readonly pixelArtEditor: PixelArtEditor;
|
||||
readonly animationEditor: AnimationEditor;
|
||||
readonly tileEditor: TileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
@@ -240,6 +245,7 @@ export class Editor implements PlayHost {
|
||||
this.particlesEditor = new ParticlesEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.vectorArtEditor = new VectorArtEditor(state);
|
||||
this.pixelArtEditor = new PixelArtEditor(state);
|
||||
this.animationEditor = new AnimationEditor(state);
|
||||
this.tileEditor = new TileEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
@@ -331,6 +337,7 @@ export class Editor implements PlayHost {
|
||||
particles: this.particlesEditor,
|
||||
texture: this.textureEditor,
|
||||
vectorart: this.vectorArtEditor,
|
||||
pixelart: this.pixelArtEditor,
|
||||
animation: this.animationEditor,
|
||||
tile: this.tileEditor,
|
||||
container: this.containerEditor,
|
||||
@@ -716,6 +723,7 @@ export class Editor implements PlayHost {
|
||||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||
if (this.tab === 'shader') this.shaderEditor.tick();
|
||||
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
|
||||
if (this.movesByItself()) requestRender();
|
||||
}
|
||||
|
||||
@@ -730,6 +738,7 @@ export class Editor implements PlayHost {
|
||||
(this.viewport.visible && this.viewport.animates) ||
|
||||
(this.gameView.visible && this.gameView.animates) ||
|
||||
this.tab === 'particles' ||
|
||||
this.tab === 'pixelart' ||
|
||||
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||
(this.tab === 'tile' && this.tileEditor.isPlaying)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Command } from '../core/commands/Command';
|
||||
import { findAnnotation } from '../core/graph';
|
||||
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { Dialog } from '../ui';
|
||||
@@ -108,7 +109,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
// A texture is never placed but worn through a property, so counting placed instances would
|
||||
// report "unused". Its frames count as the texture itself.
|
||||
const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId];
|
||||
const ids =
|
||||
asset.type === 'texture'
|
||||
? textureRefIds(asset)
|
||||
: asset.type === 'pixelart'
|
||||
? pixelArtRefIds(asset)
|
||||
: [assetId];
|
||||
const references = findPropertyUsages(state.project, ids).length;
|
||||
// An asset that draws its own picture (#185) is worn the same way, only without frames.
|
||||
const picture = asset.type === 'texture' || generatesPictures(asset.type);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
vectorart: 'vectorart',
|
||||
pixelart: 'pixelart',
|
||||
animation: 'animation',
|
||||
tile: 'tile',
|
||||
container: 'container',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Widget,
|
||||
} from '../ui';
|
||||
import { particleLibrary } from '../viewport/ParticleLibrary';
|
||||
import { pixelCels } from '../viewport/PixelCels';
|
||||
import { shaderLibrary } from '../viewport/ShaderLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
@@ -32,6 +33,7 @@ import { currentNodePalette } from './graph/NodePalette';
|
||||
import { currentSoundPicker } from './sound/SoundPicker';
|
||||
import { currentImagePicker } from './texture/ImagePicker';
|
||||
import { currentTilemapWidgets } from './panels/TilemapPanel';
|
||||
import { currentResizeDialog } from './pixelart/ResizeCanvasDialog';
|
||||
import { currentGradientBar, currentShapeList } from './inspector/shapeSection';
|
||||
import type { SnapToggle } from './graph/SnapToggle';
|
||||
import type { EditorState, TilemapTool } from './state';
|
||||
@@ -478,6 +480,107 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
|
||||
},
|
||||
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
|
||||
/** The Pixel art tab (#130): where its controls are, what it paints with, and the asset's pixels. */
|
||||
pixelArt: {
|
||||
current: () => editor.pixelArtEditor.currentAssetId,
|
||||
info: () => editor.pixelArtEditor.shownInfo,
|
||||
/** Where the centre of canvas pixel (x, y) is on screen. */
|
||||
pixelAt: (x: number, y: number) => editor.pixelArtEditor.canvas.pixelToScreen(x, y),
|
||||
zoom: () => editor.pixelArtEditor.canvas.zoom,
|
||||
tool: () => editor.pixelArtEditor.canvas.tool,
|
||||
color: () => editor.pixelArtEditor.canvas.color,
|
||||
frameId: () => editor.pixelArtEditor.canvas.frameId,
|
||||
layerId: () => editor.pixelArtEditor.canvas.layerId,
|
||||
/** A control of the side panel or the header, scrolled into view: its centre and whether it is enabled. */
|
||||
control: (name: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
const byName: Record<string, Widget | undefined> = {
|
||||
brush: side.brushButton,
|
||||
filled: side.filledBox,
|
||||
contiguous: side.contiguousBox,
|
||||
color: side.colorField,
|
||||
addColor: side.addColorButton,
|
||||
removeColor: side.removeColorButton,
|
||||
createPalette: side.createPaletteButton,
|
||||
layerName: side.layerName,
|
||||
layerOpacity: side.layerOpacity,
|
||||
onion: side.onionBox,
|
||||
tiles: side.tilesBox,
|
||||
fps: side.fpsField,
|
||||
palette: side.palette,
|
||||
preview: side.preview,
|
||||
resize: editor.pixelArtEditor.resizeButton,
|
||||
...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])),
|
||||
...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])),
|
||||
...Object.fromEntries(Object.entries(side.frameButtons).map(([k, b]) => [`frame:${k}`, b])),
|
||||
};
|
||||
const widget = byName[name];
|
||||
if (!widget || !widget.visible) return null;
|
||||
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
|
||||
return {
|
||||
...centreOf(widget),
|
||||
enabled: widget.enabled,
|
||||
text: widget instanceof Button ? widget.text : '',
|
||||
};
|
||||
},
|
||||
swatch: (index: number) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.palette);
|
||||
return side.palette.swatchGlobal(index);
|
||||
},
|
||||
layerRow: (layerId: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.layers);
|
||||
return side.layers.rowGlobal(layerId);
|
||||
},
|
||||
frameRow: (frameId: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.frames);
|
||||
return side.frames.rowGlobal(frameId);
|
||||
},
|
||||
previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex,
|
||||
/** Canvas, layers and frames as the model holds them. */
|
||||
asset: (assetId: string) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
return asset?.type === 'pixelart'
|
||||
? (JSON.parse(
|
||||
JSON.stringify({
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
layers: asset.layers,
|
||||
frames: asset.frames,
|
||||
props: asset.props,
|
||||
}),
|
||||
) as unknown)
|
||||
: null;
|
||||
},
|
||||
/** The palette indices of a cel, row by row; null for an empty cel, 'loading' while it loads. */
|
||||
cel: (file: string) => {
|
||||
const cel = pixelCels.read(file);
|
||||
return cel === undefined
|
||||
? 'loading'
|
||||
: cel === null
|
||||
? null
|
||||
: { width: cel.width, height: cel.height, data: [...cel.data] };
|
||||
},
|
||||
/** Cels painted here that the server does not hold yet. */
|
||||
pending: () => pixelCels.pending(state.project).length,
|
||||
resizeDialog: () => {
|
||||
const dialog = currentResizeDialog();
|
||||
if (!dialog) return null;
|
||||
return {
|
||||
...dialog.debugInfo(),
|
||||
width: centreOf(dialog.widthField),
|
||||
height: centreOf(dialog.heightField),
|
||||
confirm: centreOf(dialog.confirm),
|
||||
anchors: Object.fromEntries(
|
||||
([0, 0.5, 1] as const).flatMap((y) =>
|
||||
([0, 0.5, 1] as const).map((x) => [`${x},${y}`, dialog.anchorButton(x, y)]),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
vectorArt: {
|
||||
current: () => vectorArtEditor.currentAssetId,
|
||||
info: () => vectorArtEditor.shownInfo,
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { nextAssetName } from '../../core/commands/naming';
|
||||
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import type { PixelArtAsset, TextureAsset } from '../../core/model/Asset';
|
||||
import { generatesPictures } from '../../core/model/generated';
|
||||
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
@@ -195,17 +196,17 @@ export class AssetPanel extends Panel {
|
||||
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
|
||||
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
|
||||
*/
|
||||
private createAnimation(texture: TextureAsset): void {
|
||||
private createAnimation(picture: TextureAsset | PixelArtAsset): void {
|
||||
const project = this.state.project;
|
||||
const pictures = picture.frames.length > 0 ? picture.frames.map((f) => f.id) : [picture.id];
|
||||
const sprite = new CreateAssetCommand(
|
||||
'sprite',
|
||||
nextAssetName(project, texture.name),
|
||||
spriteFromTexture(project, texture.id),
|
||||
nextAssetName(project, picture.name),
|
||||
spriteFromTexture(project, pictures[0] ?? picture.id),
|
||||
);
|
||||
const create = new CreateAssetCommand('animation', nextAssetName(project, texture.name));
|
||||
const create = new CreateAssetCommand('animation', nextAssetName(project, picture.name));
|
||||
const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id);
|
||||
const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture');
|
||||
const pictures = texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id];
|
||||
const keys = pictures.map(
|
||||
(id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id),
|
||||
);
|
||||
@@ -222,9 +223,27 @@ export class AssetPanel extends Panel {
|
||||
if (type === 'texture') this.importTextures();
|
||||
else if (type === 'font') this.importFonts();
|
||||
else if (type === 'sound') this.importSounds();
|
||||
else if (type === 'pixelart') this.createPixelArt();
|
||||
else this.createAsset(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel art is painted in a palette the project shares (#130, answer 2): a new one takes the first
|
||||
* palette there is, and where there is none a palette is created along with it, in one undo step.
|
||||
*/
|
||||
private createPixelArt(): void {
|
||||
const project = this.state.project;
|
||||
const existing = groupAssets(project.assets).find((group) => group.type === 'palette')?.assets[0];
|
||||
const palette = existing
|
||||
? null
|
||||
: new CreateAssetCommand('palette', nextAssetName(project, ASSET_TYPE_LABELS.palette));
|
||||
const art = new CreateAssetCommand('pixelart', nextAssetName(project, ASSET_TYPE_LABELS.pixelart), {
|
||||
[PALETTE_KEY]: existing?.id ?? palette?.asset.id ?? '',
|
||||
});
|
||||
const commands = palette ? [palette, art] : [art];
|
||||
if (this.state.execute(oneStep(art.label, commands))) this.state.openAsset(art.asset.id);
|
||||
}
|
||||
|
||||
private importTextures(): void {
|
||||
if (!this.textures) {
|
||||
this.state.setNotice('Image upload is not available.');
|
||||
@@ -309,12 +328,16 @@ export class AssetPanel extends Panel {
|
||||
// A drawn picture (#185) is worn like a texture, so it makes a sprite the same way. Only the
|
||||
// animation needs frames, which a drawn picture has none of.
|
||||
if (asset.type === 'texture' || generatesPictures(asset.type)) {
|
||||
// A sequence of pixel art frames makes a sprite of its first frame rather than of the strip.
|
||||
const worn = asset.type === 'pixelart' ? pixelArtWornId(asset) : id;
|
||||
items.push({
|
||||
label: asset.type === 'texture' ? 'Create sprite from texture' : 'Create sprite from picture',
|
||||
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)),
|
||||
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, worn)),
|
||||
});
|
||||
if (asset.type === 'texture')
|
||||
items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) });
|
||||
if (asset.type === 'pixelart' && asset.frames.length > 1)
|
||||
items.push({ label: 'Create animation from frames', onSelect: () => this.createAnimation(asset) });
|
||||
}
|
||||
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
||||
const menu = new Menu(items);
|
||||
|
||||
@@ -19,6 +19,9 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
// Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the
|
||||
// click meant for the menu (see the test below the labels).
|
||||
vectorart: 'Vector drawings',
|
||||
// Not "Pixel art" either, for the same reason.
|
||||
pixelart: 'Pixel drawings',
|
||||
palette: 'Palettes',
|
||||
animation: 'Animations',
|
||||
tile: 'Tiles',
|
||||
tilemap: 'Tilemaps',
|
||||
|
||||
@@ -11,7 +11,9 @@ import container from './icons/container.svg?raw';
|
||||
import font from './icons/font.svg?raw';
|
||||
import game from './icons/game.svg?raw';
|
||||
import list from './icons/list.svg?raw';
|
||||
import palette from './icons/palette.svg?raw';
|
||||
import particles from './icons/particles.svg?raw';
|
||||
import pixelart from './icons/pixelart.svg?raw';
|
||||
import plugins from './icons/plugins.svg?raw';
|
||||
import rigidbody from './icons/rigidbody.svg?raw';
|
||||
import scene from './icons/scene.svg?raw';
|
||||
@@ -33,7 +35,9 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
collider,
|
||||
container,
|
||||
font,
|
||||
palette,
|
||||
particles,
|
||||
pixelart,
|
||||
rigidbody,
|
||||
scene,
|
||||
shader,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- Four swatches in a grid: the colours a picture may be painted with. -->
|
||||
<rect x="-6.5" y="-6.5" width="5.5" height="5.5" fill="currentColor" />
|
||||
<rect x="1" y="-6.5" width="5.5" height="5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="-6.5" y="1" width="5.5" height="5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="1" y="1" width="5.5" height="5.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- A pixel heart: a picture made of square pixels, nothing smooth about it. -->
|
||||
<path d="M-5 -6 H-1 V-4 H1 V-6 H5 V-4 H7 V0 H5 V2 H3 V4 H1 V6 H-1 V4 H-3 V2 H-5 V0 H-7 V-4 H-5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,79 @@
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
|
||||
const CELL = 20;
|
||||
const GAP = 2;
|
||||
export const PALETTE_COLUMNS = 8;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class PaletteGrid extends Widget {
|
||||
private readonly swatches = new Graphics();
|
||||
private colors: readonly number[] = [];
|
||||
private picked = 1;
|
||||
onPick: (index: number) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.swatches);
|
||||
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);
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
setColors(colors: readonly number[], picked: number): void {
|
||||
const grew =
|
||||
Math.ceil((colors.length + 1) / PALETTE_COLUMNS) !==
|
||||
Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS);
|
||||
this.colors = colors;
|
||||
this.picked = picked;
|
||||
if (grew) (this.parent as Widget | null)?.invalidate();
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 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 at = this.cellAt(index);
|
||||
return { x: p.x + at.x + CELL / 2, y: p.y + at.y + CELL / 2 };
|
||||
}
|
||||
|
||||
private cellAt(index: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (index % PALETTE_COLUMNS) * (CELL + GAP),
|
||||
y: Math.floor(index / PALETTE_COLUMNS) * (CELL + GAP),
|
||||
};
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.swatches.clear();
|
||||
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)
|
||||
.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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* The Pixel art tab's canvas (#130). It shows one frame of the asset – its layers composed exactly
|
||||
* as the picture path composes them – and paints on one layer of it. A stroke works on a copy of that
|
||||
* layer's pixels and shows it live; on release the copy becomes a cel file and one command puts it in
|
||||
* place, so one stroke is one undo step (answer 9).
|
||||
*
|
||||
* The view zooms in whole steps only (1x to 32x) and pans by whole screen pixels, so every canvas
|
||||
* pixel is a square of the same size on screen and the grid lines fall between them.
|
||||
*/
|
||||
import {
|
||||
CanvasSource,
|
||||
Container,
|
||||
type FederatedPointerEvent,
|
||||
type FederatedWheelEvent,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Text,
|
||||
Texture,
|
||||
} from 'pixi.js';
|
||||
import { EditPixelArtCommand } from '../../core/commands/pixelArtCommands';
|
||||
import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset';
|
||||
import {
|
||||
celOf,
|
||||
composeFrame,
|
||||
findLayer,
|
||||
frameIndexOf,
|
||||
paletteColors,
|
||||
type CelReader,
|
||||
} from '../../core/model/pixelart';
|
||||
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
|
||||
import {
|
||||
cloneCel,
|
||||
ellipsePoints,
|
||||
floodFill,
|
||||
linePoints,
|
||||
type PixelPoint,
|
||||
pixelAt,
|
||||
rectPoints,
|
||||
stamp,
|
||||
stampAll,
|
||||
} from '../../core/model/pixelDraw';
|
||||
import { withCels } from '../../core/model/pixelEdit';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { cancelFrameWork, onFrame, type Size, Theme, textStyle, ui, Widget } from '../../ui';
|
||||
import { pixelCels } from '../../viewport/PixelCels';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
import type { SheetView } from '../texture/sheetView';
|
||||
import { centredView, fitZoom, GRID_FROM_ZOOM, pixelUnder, stepZoom } from './pixelView';
|
||||
|
||||
export type PixelTool = 'pencil' | 'eraser' | 'fill' | 'picker' | 'line' | 'rect' | 'ellipse';
|
||||
|
||||
export const PIXEL_TOOLS: readonly PixelTool[] = [
|
||||
'pencil',
|
||||
'eraser',
|
||||
'fill',
|
||||
'picker',
|
||||
'line',
|
||||
'rect',
|
||||
'ellipse',
|
||||
];
|
||||
|
||||
export const PIXEL_TOOL_LABELS: Record<PixelTool, string> = {
|
||||
pencil: 'Pencil',
|
||||
eraser: 'Eraser',
|
||||
fill: 'Fill',
|
||||
picker: 'Picker',
|
||||
line: 'Line',
|
||||
rect: 'Rectangle',
|
||||
ellipse: 'Ellipse',
|
||||
};
|
||||
|
||||
/** How strongly the neighbouring frames show through while onion skin is on. */
|
||||
const ONION_ALPHA = 0.3;
|
||||
/** A stroke's pixels stand in the frame under this name while it runs; no cel file looks like it. */
|
||||
const WORKING = 'working:';
|
||||
|
||||
/** A stroke in progress: the layer's pixels as they were, and as the stroke has left them so far. */
|
||||
interface Stroke {
|
||||
tool: PixelTool;
|
||||
frameId: string;
|
||||
layerId: string;
|
||||
base: CelPixels;
|
||||
working: CelPixels;
|
||||
start: PixelPoint;
|
||||
last: PixelPoint;
|
||||
}
|
||||
|
||||
/** One picture the canvas draws a frame into; reused as long as the canvas keeps its size. */
|
||||
interface FramePicture {
|
||||
canvas: OffscreenCanvas;
|
||||
texture: Texture;
|
||||
}
|
||||
|
||||
export class PixelArtCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly content = new Container();
|
||||
private readonly checker = new Graphics();
|
||||
private readonly onion = new Container();
|
||||
private readonly tiles = new Container();
|
||||
private readonly art = new Sprite();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly clip = new Graphics();
|
||||
private readonly pictures = new Map<string, FramePicture>();
|
||||
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
||||
private assetId = '';
|
||||
private needsFit = true;
|
||||
private panning: { x: number; y: number; panX: number; panY: number } | null = null;
|
||||
private stroke: Stroke | null = null;
|
||||
private hover: PixelPoint | null = null;
|
||||
/** Which frame and layer is being painted, per asset, so a tab switch comes back to them. */
|
||||
private readonly places = new Map<string, { frameId: string; layerId: string }>();
|
||||
private readonly stopCels: () => void;
|
||||
private readonly frameKey = 'pixelart:stroke';
|
||||
|
||||
tool: PixelTool = 'pencil';
|
||||
brush = 1;
|
||||
/** Rectangles and ellipses are drawn filled rather than as an outline. */
|
||||
filled = false;
|
||||
/** Fill reaches only the area it is poured into; off, every pixel of that colour. */
|
||||
contiguous = true;
|
||||
/** The palette index painted with; 0 paints transparent. */
|
||||
color = 1;
|
||||
showOnion = false;
|
||||
showTiles = false;
|
||||
/** Told whenever something the side panel shows changes here: colour, frame, layer. */
|
||||
onChange: () => void = () => {};
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.content.addChild(this.checker, this.onion, this.tiles, this.art, this.grid, this.overlay);
|
||||
this.addChild(this.bg, this.content, this.hint, this.clip);
|
||||
this.content.mask = this.clip;
|
||||
this.content.eventMode = 'none';
|
||||
this.hint.eventMode = 'none';
|
||||
this.focusable = true;
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('pointerleave', () => {
|
||||
this.hover = null;
|
||||
this.redraw();
|
||||
});
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('load', () => {
|
||||
this.places.clear();
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
});
|
||||
this.stopCels = pixelCels.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.stopCels();
|
||||
for (const picture of this.pictures.values()) picture.texture.destroy(true);
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.cancelStroke();
|
||||
this.assetId = assetId;
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
saveView(): { view: SheetView; needsFit: boolean } {
|
||||
return { view: { ...this.view }, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { ...view };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get zoom(): number {
|
||||
return this.view.zoom;
|
||||
}
|
||||
|
||||
asset(): PixelArtAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset?.type === 'pixelart' ? asset : null;
|
||||
}
|
||||
|
||||
/** The frame being painted; the first one while none was picked or the picked one is gone. */
|
||||
get frameId(): string {
|
||||
const asset = this.asset();
|
||||
if (!asset) return '';
|
||||
const wanted = this.places.get(asset.id)?.frameId ?? '';
|
||||
return frameIndexOf(asset, wanted) >= 0 ? wanted : (asset.frames[0]?.id ?? '');
|
||||
}
|
||||
|
||||
/** The layer being painted; the top one while none was picked or the picked one is gone. */
|
||||
get layerId(): string {
|
||||
const asset = this.asset();
|
||||
if (!asset) return '';
|
||||
const wanted = this.places.get(asset.id)?.layerId ?? '';
|
||||
return findLayer(asset, wanted) ? wanted : (asset.layers[asset.layers.length - 1]?.id ?? '');
|
||||
}
|
||||
|
||||
selectFrame(frameId: string): void {
|
||||
this.place({ frameId });
|
||||
}
|
||||
|
||||
selectLayer(layerId: string): void {
|
||||
this.place({ layerId });
|
||||
}
|
||||
|
||||
private place(change: Partial<{ frameId: string; layerId: string }>): void {
|
||||
if (!this.assetId) return;
|
||||
this.cancelStroke();
|
||||
this.places.set(this.assetId, { frameId: this.frameId, layerId: this.layerId, ...change });
|
||||
this.redraw();
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
setColor(index: number): void {
|
||||
this.color = index;
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
setTool(tool: PixelTool): void {
|
||||
this.cancelStroke();
|
||||
this.tool = tool;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Where a canvas pixel's centre sits on screen – the check scripts aim with it. */
|
||||
pixelToScreen(x: number, y: number): { x: number; y: number } {
|
||||
const origin = this.getGlobalPosition();
|
||||
return {
|
||||
x: origin.x + this.view.panX + (x + 0.5) * this.view.zoom,
|
||||
y: origin.y + this.view.panY + (y + 0.5) * this.view.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
private frame(asset: PixelArtAsset): PixelFrame | undefined {
|
||||
return asset.frames[frameIndexOf(asset, this.frameId)];
|
||||
}
|
||||
|
||||
/** The pixels of a layer on the current frame: undefined while loading, empty where it has none. */
|
||||
private layerPixels(asset: PixelArtAsset, layerId: string): CelPixels | undefined {
|
||||
const frame = this.frame(asset);
|
||||
if (!frame) return undefined;
|
||||
const cel = pixelCels.read(celOf(frame, layerId));
|
||||
return cel === null ? emptyCel(asset.width, asset.height) : cel;
|
||||
}
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
this.panning = { x: local.x, y: local.y, panX: this.view.panX, panY: this.view.panY };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
ui().focus.focus(this);
|
||||
e.stopPropagation();
|
||||
const at = pixelUnder(this.view, local.x, local.y);
|
||||
// Alt picks with any tool (answer 6), so the colour is at hand without leaving the pencil.
|
||||
if (this.tool === 'picker' || e.altKey) {
|
||||
this.pick(asset, at);
|
||||
return;
|
||||
}
|
||||
const layer = findLayer(asset, this.layerId);
|
||||
if (!layer) return;
|
||||
if (layer.hidden) {
|
||||
this.state.setNotice('The layer is hidden – show it to paint on it.');
|
||||
return;
|
||||
}
|
||||
const pixels = this.layerPixels(asset, layer.id);
|
||||
if (!pixels) {
|
||||
this.state.setNotice('The pixels of this layer are still loading.');
|
||||
return;
|
||||
}
|
||||
const base = cloneCel(pixels);
|
||||
const stroke: Stroke = {
|
||||
tool: this.tool,
|
||||
frameId: this.frameId,
|
||||
layerId: layer.id,
|
||||
base,
|
||||
working: cloneCel(base),
|
||||
start: at,
|
||||
last: at,
|
||||
};
|
||||
if (this.tool === 'fill') {
|
||||
floodFill(stroke.working, at, this.color, this.contiguous);
|
||||
this.commit(stroke);
|
||||
return;
|
||||
}
|
||||
this.stroke = stroke;
|
||||
this.paintTo(stroke, at);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** What the stroke does as the pointer reaches `at`: a trail for the brush, the whole shape anew for the rest. */
|
||||
private paintTo(stroke: Stroke, at: PixelPoint): void {
|
||||
const index = stroke.tool === 'eraser' ? 0 : this.color;
|
||||
if (stroke.tool === 'pencil' || stroke.tool === 'eraser') {
|
||||
stampAll(stroke.working, linePoints(stroke.last, at), this.brush, index);
|
||||
stroke.last = at;
|
||||
return;
|
||||
}
|
||||
stroke.working = cloneCel(stroke.base);
|
||||
stroke.last = at;
|
||||
const points =
|
||||
stroke.tool === 'line'
|
||||
? linePoints(stroke.start, at)
|
||||
: stroke.tool === 'rect'
|
||||
? rectPoints(stroke.start, at, this.filled)
|
||||
: ellipsePoints(stroke.start, at, this.filled);
|
||||
// A filled shape is filled pixel by pixel; the brush only widens a line or an outline.
|
||||
if (this.filled && stroke.tool !== 'line') for (const p of points) stamp(stroke.working, p, 1, index);
|
||||
else stampAll(stroke.working, points, this.brush, index);
|
||||
}
|
||||
|
||||
/** The colour under the pointer: the top visible layer that paints the pixel, else transparent. */
|
||||
private pick(asset: PixelArtAsset, at: PixelPoint): void {
|
||||
for (const layer of [...asset.layers].reverse()) {
|
||||
if (layer.hidden) continue;
|
||||
const pixels = this.layerPixels(asset, layer.id);
|
||||
const index = pixels ? pixelAt(pixels, at.x, at.y) : 0;
|
||||
if (index !== 0) {
|
||||
this.setColor(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setColor(0);
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
if (this.panning) {
|
||||
this.view = {
|
||||
...this.view,
|
||||
panX: Math.round(this.panning.panX + local.x - this.panning.x),
|
||||
panY: Math.round(this.panning.panY + local.y - this.panning.y),
|
||||
};
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const at = pixelUnder(this.view, local.x, local.y);
|
||||
const stroke = this.stroke;
|
||||
if (stroke) {
|
||||
if (at.x !== stroke.last.x || at.y !== stroke.last.y) {
|
||||
this.paintTo(stroke, at);
|
||||
onFrame(this.frameKey, () => this.redraw());
|
||||
}
|
||||
return;
|
||||
}
|
||||
const hover = this.hover;
|
||||
if (!hover || hover.x !== at.x || hover.y !== at.y) {
|
||||
this.hover = at;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
const stroke = this.stroke;
|
||||
this.stroke = null;
|
||||
cancelFrameWork(this.frameKey);
|
||||
if (stroke) this.commit(stroke);
|
||||
}
|
||||
|
||||
private cancelStroke(): void {
|
||||
this.stroke = null;
|
||||
cancelFrameWork(this.frameKey);
|
||||
}
|
||||
|
||||
/** The stroke's pixels become a cel file, and one command puts it in place – nothing when nothing changed. */
|
||||
private commit(stroke: Stroke): void {
|
||||
const asset = this.asset();
|
||||
const frame = asset?.frames.find((f) => f.id === stroke.frameId);
|
||||
if (!asset || !frame) return;
|
||||
const file = pixelCels.put(stroke.working);
|
||||
if (file === celOf(frame, stroke.layerId)) {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const change = { frameId: stroke.frameId, layerId: stroke.layerId, file };
|
||||
this.state.execute(
|
||||
new EditPixelArtCommand(asset.id, PIXEL_TOOL_LABELS[stroke.tool], (s) => withCels(s, [change])),
|
||||
);
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
|
||||
if (zoom !== this.view.zoom) {
|
||||
// The pixel under the pointer stays under it.
|
||||
const x = (local.x - this.view.panX) / this.view.zoom;
|
||||
const y = (local.y - this.view.panY) / this.view.zoom;
|
||||
this.view = { panX: Math.round(local.x - x * zoom), panY: Math.round(local.y - y * zoom), zoom };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
this.onChange();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** A frame composed into a picture of its own; `slot` keeps the current frame and its neighbours apart. */
|
||||
private framePicture(
|
||||
slot: string,
|
||||
asset: PixelArtAsset,
|
||||
frame: PixelFrame,
|
||||
read: CelReader,
|
||||
): Texture | null {
|
||||
const rgba = composeFrame(asset, paletteColors(this.state.project, asset), frame, read);
|
||||
if (!rgba) return null;
|
||||
let picture = this.pictures.get(slot);
|
||||
if (!picture || picture.canvas.width !== asset.width || picture.canvas.height !== asset.height) {
|
||||
picture?.texture.destroy(true);
|
||||
const canvas = new OffscreenCanvas(asset.width, asset.height);
|
||||
picture = {
|
||||
canvas,
|
||||
texture: new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) }),
|
||||
};
|
||||
this.pictures.set(slot, picture);
|
||||
}
|
||||
picture.canvas.getContext('2d')?.putImageData(new ImageData(rgba, asset.width, asset.height), 0, 0);
|
||||
picture.texture.source.update();
|
||||
return picture.texture;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.checker.clear();
|
||||
this.grid.clear();
|
||||
this.overlay.clear();
|
||||
this.onion.removeChildren().forEach((child) => child.destroy());
|
||||
this.tiles.removeChildren().forEach((child) => child.destroy());
|
||||
this.art.visible = false;
|
||||
const asset = this.asset();
|
||||
const frame = asset ? this.frame(asset) : undefined;
|
||||
if (!asset || !frame) {
|
||||
this.setHint(asset ? '' : 'Open a pixel art asset to paint on it.');
|
||||
return;
|
||||
}
|
||||
if (this.needsFit && this.w > 0 && this.h > 0) {
|
||||
const zoom = fitZoom(asset.width, asset.height, this.w, this.h);
|
||||
this.view = centredView(asset.width, asset.height, this.w, this.h, zoom);
|
||||
this.needsFit = false;
|
||||
// The readout names the zoom; the fit happens once the canvas has its size, after the tab told it.
|
||||
queueMicrotask(() => this.onChange());
|
||||
}
|
||||
const { panX, panY, zoom } = this.view;
|
||||
const w = asset.width * zoom;
|
||||
const h = asset.height * zoom;
|
||||
drawChecker(this.checker, panX, panY, w, h, this.w, this.h);
|
||||
|
||||
const stroke = this.stroke;
|
||||
const shown: PixelFrame = stroke
|
||||
? { id: frame.id, cels: { ...frame.cels, [stroke.layerId]: WORKING } }
|
||||
: frame;
|
||||
const read: CelReader = (file) => (file === WORKING ? (stroke?.working ?? null) : pixelCels.read(file));
|
||||
const texture = this.framePicture('frame', asset, shown, read);
|
||||
if (!texture) {
|
||||
this.setHint('Loading pixels…');
|
||||
return;
|
||||
}
|
||||
this.setHint('');
|
||||
this.putSprite(this.art, texture, panX, panY, zoom);
|
||||
this.art.visible = true;
|
||||
|
||||
if (this.showOnion) this.drawOnion(asset, frame);
|
||||
if (this.showTiles)
|
||||
for (let ty = -1; ty <= 1; ty++)
|
||||
for (let tx = -1; tx <= 1; tx++) {
|
||||
if (tx === 0 && ty === 0) continue;
|
||||
const tile = new Sprite(texture);
|
||||
this.putSprite(tile, texture, panX + tx * w, panY + ty * h, zoom);
|
||||
this.tiles.addChild(tile);
|
||||
}
|
||||
|
||||
if (zoom >= GRID_FROM_ZOOM) this.drawGrid(asset, panX, panY, zoom);
|
||||
this.overlay.rect(panX - 0.5, panY - 0.5, w + 1, h + 1).stroke({ width: 1, color: Theme.colors.border });
|
||||
const hover = this.hover;
|
||||
if (hover && !stroke && this.tool !== 'fill' && this.tool !== 'picker') {
|
||||
const n = this.brush;
|
||||
const x0 = hover.x - Math.floor((n - 1) / 2);
|
||||
const y0 = hover.y - Math.floor((n - 1) / 2);
|
||||
this.overlay
|
||||
.rect(panX + x0 * zoom, panY + y0 * zoom, n * zoom, n * zoom)
|
||||
.stroke({ width: 1, color: Theme.colors.accent });
|
||||
}
|
||||
}
|
||||
|
||||
private putSprite(sprite: Sprite, texture: Texture, x: number, y: number, zoom: number): void {
|
||||
sprite.texture = texture;
|
||||
sprite.position.set(x, y);
|
||||
sprite.scale.set(zoom);
|
||||
}
|
||||
|
||||
/** The frames before and after the one being painted, faintly, under it (answer 5). */
|
||||
private drawOnion(asset: PixelArtAsset, frame: PixelFrame): void {
|
||||
const index = asset.frames.indexOf(frame);
|
||||
for (const [slot, neighbour] of [
|
||||
['before', asset.frames[index - 1]],
|
||||
['after', asset.frames[index + 1]],
|
||||
] as const) {
|
||||
if (!neighbour) continue;
|
||||
const texture = this.framePicture(slot, asset, neighbour, (file) => pixelCels.read(file));
|
||||
if (!texture) continue;
|
||||
const sprite = new Sprite(texture);
|
||||
this.putSprite(sprite, texture, this.view.panX, this.view.panY, this.view.zoom);
|
||||
sprite.alpha = ONION_ALPHA;
|
||||
this.onion.addChild(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
/** One line between every two pixels, only where the widget shows them. */
|
||||
private drawGrid(asset: PixelArtAsset, panX: number, panY: number, zoom: number): void {
|
||||
const x0 = Math.max(1, Math.ceil(-panX / zoom));
|
||||
const x1 = Math.min(asset.width - 1, Math.floor((this.w - panX) / zoom));
|
||||
const y0 = Math.max(1, Math.ceil(-panY / zoom));
|
||||
const y1 = Math.min(asset.height - 1, Math.floor((this.h - panY) / zoom));
|
||||
const top = Math.max(0, panY);
|
||||
const bottom = Math.min(this.h, panY + asset.height * zoom);
|
||||
const left = Math.max(0, panX);
|
||||
const right = Math.min(this.w, panX + asset.width * zoom);
|
||||
for (let x = x0; x <= x1; x++)
|
||||
this.grid.moveTo(panX + x * zoom + 0.5, top).lineTo(panX + x * zoom + 0.5, bottom);
|
||||
for (let y = y0; y <= y1; y++)
|
||||
this.grid.moveTo(left, panY + y * zoom + 0.5).lineTo(right, panY + y * zoom + 0.5);
|
||||
this.grid.stroke({ width: 1, color: 0x000000, alpha: 0.25 });
|
||||
}
|
||||
|
||||
private setHint(text: string): void {
|
||||
this.hint.text = text;
|
||||
this.hint.position.set(Theme.padding, this.h - this.hint.height - Theme.padding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Asset, PixelArtAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, type Splitter, type Widget } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { PreviewSplit } from '../PreviewSplit';
|
||||
import type { EditorState } from '../state';
|
||||
import { PixelArtCanvas } from './PixelArtCanvas';
|
||||
import { PixelArtSide } from './PixelArtSide';
|
||||
import { resizeCanvas } from './pixelActions';
|
||||
import { openResizeDialog, ResizeCanvasDialog } from './ResizeCanvasDialog';
|
||||
|
||||
/** Room kept for the "32 × 32 px · 4 frames · 8x" readout. */
|
||||
const INFO_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* The Pixel art tab (#130): the canvas that paints, beside it the panel with tools, colours, layers,
|
||||
* frames and the playing preview. The asset's palette and filter are rows of the inspector.
|
||||
*/
|
||||
export class PixelArtEditor extends AssetTabEditor<PixelArtAsset, PixelArtCanvas> {
|
||||
readonly resizeButton = new Button('Resize…', { onClick: () => this.openResize() });
|
||||
/** Assigned by `body`; public for the check scripts, which press everything on it. */
|
||||
side!: PixelArtSide;
|
||||
splitter!: Splitter;
|
||||
private started = performance.now();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
const canvas = new PixelArtCanvas(state);
|
||||
this.mount(canvas, [this.resizeButton]);
|
||||
// The colour, the frame, the layer and the zoom live on the canvas; the panel and the readout show them.
|
||||
canvas.onChange = () => {
|
||||
this.side.refresh();
|
||||
this.refresh();
|
||||
};
|
||||
}
|
||||
|
||||
protected override body(canvas: PixelArtCanvas): Widget {
|
||||
this.side = new PixelArtSide(this.state, canvas);
|
||||
const split = new PreviewSplit(canvas, this.side);
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
/** Called every frame while the tab is showing: the preview is a moving picture. */
|
||||
tick(): void {
|
||||
this.side.preview.tick((performance.now() - this.started) / 1000);
|
||||
}
|
||||
|
||||
private openResize(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
openResizeDialog(
|
||||
new ResizeCanvasDialog(asset.width, asset.height, (width, height, anchor) => {
|
||||
const current = findAsset(this.state.project, asset.id);
|
||||
if (current?.type === 'pixelart') resizeCanvas(this.state, current, width, height, anchor);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override showAsset(assetId: string): void {
|
||||
super.showAsset(assetId);
|
||||
this.side.refresh();
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind !== 'asset') return '';
|
||||
return findAsset(this.state.project, s.assetId)?.type === 'pixelart' ? s.assetId : '';
|
||||
}
|
||||
|
||||
override isMine(asset: Asset): asset is PixelArtAsset {
|
||||
return asset.type === 'pixelart';
|
||||
}
|
||||
|
||||
protected override infoText(asset: PixelArtAsset): string {
|
||||
const count = asset.frames.length;
|
||||
return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'} · ${this.canvas.zoom}x`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: PixelArtAsset | null): void {
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
this.resizeButton.setEnabled(asset !== null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* The panel beside the pixel art canvas (#130): tools, colours, layers, frames, view and preview in
|
||||
* one column, so everything the canvas paints with is next to it. The rows the inspector shows for
|
||||
* the asset – palette and filter – stay in the inspector, as with every other asset.
|
||||
*/
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { nextAssetName } from '../../core/commands/naming';
|
||||
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
|
||||
import { colorAt, frameLabel, PALETTE_KEY, paletteAssetOf, paletteColors } from '../../core/model/pixelart';
|
||||
import { BRUSH_MAX, BRUSH_MIN } from '../../core/model/pixelDraw';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ColorField,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
ListView,
|
||||
Menu,
|
||||
NumberField,
|
||||
ScrollContainer,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { PaletteGrid } from './PaletteGrid';
|
||||
import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas';
|
||||
import {
|
||||
addFrame,
|
||||
addLayer,
|
||||
addPaletteColor,
|
||||
mergeLayerDown,
|
||||
moveFrame,
|
||||
moveLayer,
|
||||
removeFrame,
|
||||
removeLastPaletteColor,
|
||||
removeLayer,
|
||||
renameLayer,
|
||||
setLayerHidden,
|
||||
setLayerOpacity,
|
||||
setPaletteColor,
|
||||
} from './pixelActions';
|
||||
import { PixelPreview } from './PixelPreview';
|
||||
|
||||
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
|
||||
const TOOLS_PER_ROW = 4;
|
||||
const LIST_ROWS = 4;
|
||||
|
||||
function brushLabel(size: number): string {
|
||||
return `Brush: ${size} px`;
|
||||
}
|
||||
|
||||
export class PixelArtSide extends ScrollContainer {
|
||||
private readonly column = new VStack({ gap: Theme.gap, padding: Theme.padding });
|
||||
/** Public for the check scripts: every control they press. */
|
||||
readonly toolButtons = new Map<PixelTool, Button>();
|
||||
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
|
||||
readonly filledBox = new Checkbox(false);
|
||||
readonly contiguousBox = new Checkbox(true);
|
||||
readonly palette = new PaletteGrid();
|
||||
readonly colorField = new ColorField(0);
|
||||
readonly addColorButton = new Button('+ Colour');
|
||||
readonly removeColorButton = new Button('Remove last');
|
||||
readonly createPaletteButton = new Button('Create palette');
|
||||
private readonly paletteNote = new Label('', { style: 'muted' });
|
||||
readonly layers = new ListView();
|
||||
readonly layerName = new TextField('');
|
||||
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
|
||||
readonly layerButtons: Record<'add' | 'remove' | 'up' | 'down' | 'merge' | 'hide', Button>;
|
||||
readonly frames = new ListView();
|
||||
readonly frameButtons: Record<'add' | 'duplicate' | 'remove' | 'earlier' | 'later', Button>;
|
||||
readonly onionBox = new Checkbox(false);
|
||||
readonly tilesBox = new Checkbox(false);
|
||||
readonly preview: PixelPreview;
|
||||
readonly fpsField = new NumberField(8, { min: 1, max: 60, step: 1, decimals: 0 });
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly canvas: PixelArtCanvas,
|
||||
) {
|
||||
super();
|
||||
this.preview = new PixelPreview(state);
|
||||
const c = this.column;
|
||||
|
||||
c.add(heading('Tools'));
|
||||
for (let i = 0; i < PIXEL_TOOLS.length; i += TOOLS_PER_ROW) {
|
||||
const tools = new HStack({ gap: Theme.gap });
|
||||
for (const tool of PIXEL_TOOLS.slice(i, i + TOOLS_PER_ROW)) {
|
||||
const button = new Button(PIXEL_TOOL_LABELS[tool], {
|
||||
toggle: true,
|
||||
onClick: () => this.setTool(tool),
|
||||
});
|
||||
this.toolButtons.set(tool, button);
|
||||
tools.add(button, 1);
|
||||
}
|
||||
c.add(tools);
|
||||
}
|
||||
this.brushButton.onClick = () => this.openBrushMenu();
|
||||
c.add(row(this.brushButton));
|
||||
this.filledBox.onChange = (on) => (this.canvas.filled = on);
|
||||
this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on);
|
||||
c.add(
|
||||
row(
|
||||
this.filledBox,
|
||||
new Label('Filled shapes'),
|
||||
new Spacer(),
|
||||
this.contiguousBox,
|
||||
new Label('Contiguous'),
|
||||
),
|
||||
);
|
||||
|
||||
c.add(heading('Colours'));
|
||||
this.palette.onPick = (index) => this.canvas.setColor(index);
|
||||
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));
|
||||
this.addColorButton.onClick = () => this.addColor();
|
||||
this.removeColorButton.onClick = () => this.removeColor();
|
||||
this.createPaletteButton.onClick = () => this.createPalette();
|
||||
c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton));
|
||||
|
||||
c.add(heading('Layers'));
|
||||
this.layers.minHeight = LIST_ROWS * this.layers.rowHeight;
|
||||
this.layers.onSelect = (id) => id && this.canvas.selectLayer(id);
|
||||
c.add(this.layers);
|
||||
this.layerName.onChange = (name) =>
|
||||
this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name));
|
||||
this.layerOpacity.onNumberChange = (value) =>
|
||||
this.withAsset((asset) => setLayerOpacity(state, asset, this.canvas.layerId, value));
|
||||
c.add(row(new Label('Name', { style: 'muted' }), this.layerName));
|
||||
c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity));
|
||||
this.layerButtons = {
|
||||
add: new Button('Add', { onClick: () => this.addLayer() }),
|
||||
remove: new Button('Delete', {
|
||||
onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)),
|
||||
}),
|
||||
up: new Button('Up', {
|
||||
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, 1)),
|
||||
}),
|
||||
down: new Button('Down', {
|
||||
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, -1)),
|
||||
}),
|
||||
merge: new Button('Merge down', {
|
||||
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
|
||||
}),
|
||||
hide: new Button('Hide', { minWidth: 56, onClick: () => this.toggleHidden() }),
|
||||
};
|
||||
const lb = this.layerButtons;
|
||||
c.add(row(lb.add, lb.remove, lb.up, lb.down));
|
||||
c.add(row(lb.merge, lb.hide));
|
||||
|
||||
c.add(heading('Frames'));
|
||||
this.frames.minHeight = LIST_ROWS * this.frames.rowHeight;
|
||||
this.frames.onSelect = (id) => id && this.canvas.selectFrame(id);
|
||||
c.add(this.frames);
|
||||
this.frameButtons = {
|
||||
add: new Button('Add', { onClick: () => this.addFrame(false) }),
|
||||
duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }),
|
||||
remove: new Button('Delete', {
|
||||
onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)),
|
||||
}),
|
||||
earlier: new Button('Earlier', {
|
||||
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, -1)),
|
||||
}),
|
||||
later: new Button('Later', {
|
||||
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, 1)),
|
||||
}),
|
||||
};
|
||||
const fb = this.frameButtons;
|
||||
c.add(row(fb.add, fb.duplicate, fb.remove));
|
||||
c.add(row(fb.earlier, fb.later));
|
||||
|
||||
c.add(heading('View'));
|
||||
this.onionBox.onChange = (on) => {
|
||||
this.canvas.showOnion = on;
|
||||
this.canvas.redraw();
|
||||
};
|
||||
this.tilesBox.onChange = (on) => {
|
||||
this.canvas.showTiles = on;
|
||||
this.canvas.redraw();
|
||||
};
|
||||
c.add(
|
||||
row(this.onionBox, new Label('Onion skin'), new Spacer(), this.tilesBox, new Label('Tile preview')),
|
||||
);
|
||||
|
||||
c.add(heading('Preview'));
|
||||
c.add(this.preview);
|
||||
this.fpsField.onNumberChange = (fps) => (this.preview.fps = fps);
|
||||
this.fpsField.minWidth = 56;
|
||||
c.add(row(new Label('Frames per second', { style: 'muted' }), new Spacer(), this.fpsField));
|
||||
|
||||
this.setContent(this.column);
|
||||
this.setTool('pencil');
|
||||
state.on('project', () => this.refresh());
|
||||
}
|
||||
|
||||
/** Scrolls the column until `widget` is in view – the check scripts reveal a control before they click it. */
|
||||
reveal(widget: Widget): void {
|
||||
const top = widget.getGlobalPosition().y - this.column.getGlobalPosition().y;
|
||||
this.ensureVisible(top, widget.h);
|
||||
}
|
||||
|
||||
setTool(tool: PixelTool): void {
|
||||
this.canvas.setTool(tool);
|
||||
for (const [t, button] of this.toolButtons) button.setToggled(t === tool);
|
||||
}
|
||||
|
||||
setBrush(size: number): void {
|
||||
this.canvas.brush = size;
|
||||
this.brushButton.setText(brushLabel(size));
|
||||
}
|
||||
|
||||
private openBrushMenu(): void {
|
||||
const items = [];
|
||||
for (let size = BRUSH_MIN; size <= BRUSH_MAX; size++)
|
||||
items.push({
|
||||
label: `${size} px`,
|
||||
checked: () => this.canvas.brush === size,
|
||||
onSelect: () => this.setBrush(size),
|
||||
});
|
||||
new Menu(items).openBelow(this.brushButton);
|
||||
}
|
||||
|
||||
private withAsset(run: (asset: PixelArtAsset) => unknown): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (asset) run(asset);
|
||||
}
|
||||
|
||||
private paletteAsset(): PaletteAsset | null {
|
||||
const asset = this.canvas.asset();
|
||||
return asset ? paletteAssetOf(this.state.project, asset) : null;
|
||||
}
|
||||
|
||||
/** The colour under the picker while it is dragged, straight into the palette, as the inspector previews. */
|
||||
private previewColor(color: number): void {
|
||||
const palette = this.paletteAsset();
|
||||
const index = this.canvas.color;
|
||||
if (!palette || index < 1 || index > palette.colors.length) return;
|
||||
this.colorBefore ??= palette.colors[index - 1]!;
|
||||
palette.colors[index - 1] = color;
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
private commitColor(color: number): void {
|
||||
const palette = this.paletteAsset();
|
||||
const index = this.canvas.color;
|
||||
if (!palette || index < 1 || index > palette.colors.length) return;
|
||||
const before = this.colorBefore ?? palette.colors[index - 1]!;
|
||||
palette.colors[index - 1] = before;
|
||||
setPaletteColor(this.state, palette, index, color);
|
||||
this.colorBefore = null;
|
||||
}
|
||||
|
||||
/** The colour the picked swatch had before a preview changed it – what the commit puts back first. */
|
||||
private colorBefore: number | null = null;
|
||||
|
||||
private addColor(): void {
|
||||
const palette = this.paletteAsset();
|
||||
const asset = this.canvas.asset();
|
||||
if (!palette || !asset) return;
|
||||
const color = colorAt(palette.colors, this.canvas.color) ?? 0xffffff;
|
||||
const index = addPaletteColor(this.state, palette, color);
|
||||
if (index > 0) this.canvas.setColor(index);
|
||||
}
|
||||
|
||||
private removeColor(): void {
|
||||
const palette = this.paletteAsset();
|
||||
if (!palette || !removeLastPaletteColor(this.state, palette)) return;
|
||||
if (this.canvas.color > palette.colors.length) this.canvas.setColor(palette.colors.length);
|
||||
}
|
||||
|
||||
/** A palette of the default colours, named on the asset – one undo step for both. */
|
||||
private createPalette(): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (!asset) return;
|
||||
const create = new CreateAssetCommand('palette', nextAssetName(this.state.project, 'Palette'));
|
||||
const assign = new SetAssetPropertyCommand(asset.id, PALETTE_KEY, create.asset.id);
|
||||
this.state.execute(oneStep(create.label, [create, assign]));
|
||||
}
|
||||
|
||||
private addLayer(): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (!asset) return;
|
||||
const id = addLayer(this.state, asset, this.canvas.layerId);
|
||||
if (id) this.canvas.selectLayer(id);
|
||||
}
|
||||
|
||||
private toggleHidden(): void {
|
||||
const asset = this.canvas.asset();
|
||||
const layer = asset?.layers.find((l) => l.id === this.canvas.layerId);
|
||||
if (asset && layer) setLayerHidden(this.state, asset, layer.id, !layer.hidden);
|
||||
}
|
||||
|
||||
private addFrame(copy: boolean): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (!asset) return;
|
||||
const id = addFrame(this.state, asset, this.canvas.frameId, copy);
|
||||
if (id) this.canvas.selectFrame(id);
|
||||
}
|
||||
|
||||
/** Lists, fields and switches as the asset and the canvas have them now. */
|
||||
refresh(): void {
|
||||
const asset = this.canvas.asset();
|
||||
this.preview.show(asset?.id ?? '');
|
||||
const palette = asset ? paletteAssetOf(this.state.project, asset) : null;
|
||||
const colors = asset ? paletteColors(this.state.project, asset) : [];
|
||||
this.palette.setColors(colors, this.canvas.color);
|
||||
const current = colorAt(colors, this.canvas.color);
|
||||
if (current !== null) this.colorField.setValue(current);
|
||||
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.paletteNote.setText(
|
||||
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours',
|
||||
);
|
||||
this.createPaletteButton.visible = asset !== null && palette === null;
|
||||
|
||||
const layerId = this.canvas.layerId;
|
||||
this.layers.setItems(
|
||||
[...(asset?.layers ?? [])].reverse().map((layer) => ({
|
||||
id: layer.id,
|
||||
label: layer.hidden ? `${layer.name} (hidden)` : layer.name,
|
||||
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
|
||||
})),
|
||||
);
|
||||
this.layers.select(layerId || null, true);
|
||||
const layer = asset?.layers.find((l) => l.id === layerId);
|
||||
this.layerName.setValue(layer?.name ?? '');
|
||||
this.layerOpacity.setNumber(layer?.opacity ?? 1);
|
||||
const index = asset && layer ? asset.layers.indexOf(layer) : -1;
|
||||
const lb = this.layerButtons;
|
||||
lb.add.setEnabled(asset !== null);
|
||||
lb.remove.setEnabled((asset?.layers.length ?? 0) > 1);
|
||||
lb.up.setEnabled(asset !== null && index >= 0 && index < asset.layers.length - 1);
|
||||
lb.down.setEnabled(index > 0);
|
||||
lb.merge.setEnabled(index > 0);
|
||||
lb.hide.setText(layer?.hidden ? 'Show' : 'Hide');
|
||||
lb.hide.setEnabled(layer !== undefined);
|
||||
|
||||
const frameId = this.canvas.frameId;
|
||||
this.frames.setItems((asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })));
|
||||
this.frames.select(frameId || null, true);
|
||||
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
|
||||
const fb = this.frameButtons;
|
||||
fb.add.setEnabled(asset !== null);
|
||||
fb.duplicate.setEnabled(frameIndex >= 0);
|
||||
fb.remove.setEnabled((asset?.frames.length ?? 0) > 1);
|
||||
fb.earlier.setEnabled(frameIndex > 0);
|
||||
fb.later.setEnabled(asset !== null && frameIndex >= 0 && frameIndex < asset.frames.length - 1);
|
||||
this.relayoutContent();
|
||||
}
|
||||
}
|
||||
|
||||
function heading(text: string): Label {
|
||||
return new Label(text.toUpperCase(), { style: 'muted' });
|
||||
}
|
||||
|
||||
function row(...widgets: Widget[]): HStack {
|
||||
const stack = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
stack.minHeight = Theme.controlHeight;
|
||||
for (const widget of widgets)
|
||||
stack.add(widget, widget instanceof Spacer || widget instanceof TextField ? 1 : 0);
|
||||
return stack;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Graphics, Sprite } from 'pixi.js';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { resolvePicture } from '../../core/model/texture';
|
||||
import { type Size, Widget } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
|
||||
const HEIGHT = 96;
|
||||
export const PREVIEW_FPS_DEFAULT = 8;
|
||||
|
||||
/**
|
||||
* The frames of a pixel art asset playing in a loop (answer 5), small, as the picture path draws them
|
||||
* – so what plays here is what a sprite wearing the frames shows. How fast is a setting of the
|
||||
* preview only: in a game the Animation asset keys the frames and says how long each one lasts.
|
||||
*/
|
||||
export class PixelPreview extends Widget {
|
||||
private readonly checker = new Graphics();
|
||||
private readonly sprite = new Sprite();
|
||||
private assetId = '';
|
||||
fps = PREVIEW_FPS_DEFAULT;
|
||||
/** The frame on show, for the check scripts. */
|
||||
shownIndex = 0;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.addChild(this.checker, this.sprite);
|
||||
this.minHeight = HEIGHT;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.assetId = assetId;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: HEIGHT };
|
||||
}
|
||||
|
||||
/** Called every frame while the tab shows: `seconds` is the clock the preview runs on. */
|
||||
tick(seconds: number): void {
|
||||
const asset = findAsset(this.state.project, this.assetId);
|
||||
const count = asset?.type === 'pixelart' ? asset.frames.length : 0;
|
||||
const index = count > 0 ? Math.floor(seconds * Math.max(0.1, this.fps)) % count : 0;
|
||||
if (index !== this.shownIndex) {
|
||||
this.shownIndex = index;
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.checker.clear();
|
||||
this.sprite.visible = false;
|
||||
const asset = findAsset(this.state.project, this.assetId);
|
||||
if (asset?.type !== 'pixelart' || this.w <= 0) return;
|
||||
const frame = asset.frames[Math.min(this.shownIndex, asset.frames.length - 1)];
|
||||
const ref = frame && resolvePicture(this.state.project, asset.frames.length > 1 ? frame.id : asset.id);
|
||||
const texture = ref ? textureLibrary.get(ref) : null;
|
||||
// Whole steps while the picture fits, so its pixels stay square; a big canvas is shrunk to fit.
|
||||
const fits = Math.min(this.w / asset.width, HEIGHT / asset.height);
|
||||
const scale = fits >= 1 ? Math.floor(fits) : fits;
|
||||
const w = asset.width * scale;
|
||||
const h = asset.height * scale;
|
||||
const x = Math.round((this.w - w) / 2);
|
||||
const y = Math.round((HEIGHT - h) / 2);
|
||||
drawChecker(this.checker, x, y, w, h, this.w, HEIGHT);
|
||||
if (!texture) return;
|
||||
this.sprite.texture = texture;
|
||||
this.sprite.position.set(x, y);
|
||||
this.sprite.width = w;
|
||||
this.sprite.height = h;
|
||||
this.sprite.visible = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ResizeAnchor } from '../../core/model/pixelDraw';
|
||||
import { PIXEL_CANVAS_MAX } from '../../core/model/pixelCel';
|
||||
import { Button, HStack, NumberField, VStack } from '../../ui';
|
||||
import { dialogSlot, FormDialog } from '../FormDialog';
|
||||
|
||||
const FIELD_WIDTH = 76;
|
||||
const ANCHOR_SIZE = 24;
|
||||
const STEPS = [0, 0.5, 1] as const;
|
||||
|
||||
const slot = dialogSlot<ResizeCanvasDialog>();
|
||||
|
||||
/** The open resize dialog, for the check scripts. */
|
||||
export const currentResizeDialog = slot.current;
|
||||
|
||||
export const openResizeDialog = slot.open;
|
||||
|
||||
/**
|
||||
* Asks for the new canvas size and where the old picture stays in it (answer 3): the nine anchor
|
||||
* buttons are the corners, edges and centre. What sticks out on the far side is cut off.
|
||||
*/
|
||||
export class ResizeCanvasDialog extends FormDialog {
|
||||
private newWidth: number;
|
||||
private newHeight: number;
|
||||
private anchor: ResizeAnchor = { x: 0, y: 0 };
|
||||
private readonly anchors: { anchor: ResizeAnchor; button: Button }[] = [];
|
||||
readonly widthField: NumberField;
|
||||
readonly heightField: NumberField;
|
||||
/** Public for the check scripts. */
|
||||
readonly confirm: Button;
|
||||
|
||||
constructor(
|
||||
width: number,
|
||||
height: number,
|
||||
onConfirm: (w: number, h: number, anchor: ResizeAnchor) => void,
|
||||
) {
|
||||
super(300, 'Resize canvas');
|
||||
this.newWidth = width;
|
||||
this.newHeight = height;
|
||||
const field = (value: number, set: (value: number) => void) => {
|
||||
const number = new NumberField(value, {
|
||||
min: 1,
|
||||
max: PIXEL_CANVAS_MAX,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
onInput: set,
|
||||
onChange: set,
|
||||
});
|
||||
number.minWidth = FIELD_WIDTH;
|
||||
return number;
|
||||
};
|
||||
this.widthField = field(width, (value) => (this.newWidth = Math.round(value)));
|
||||
this.heightField = field(height, (value) => (this.newHeight = Math.round(value)));
|
||||
this.content.add(this.formRow(this.fieldLabel('Width'), this.widthField));
|
||||
this.content.add(this.formRow(this.fieldLabel('Height'), this.heightField));
|
||||
|
||||
const grid = new VStack({ gap: 2 });
|
||||
for (const y of STEPS) {
|
||||
const row = new HStack({ gap: 2 });
|
||||
for (const x of STEPS) {
|
||||
const anchor: ResizeAnchor = { x, y };
|
||||
const button = new Button('', {
|
||||
width: ANCHOR_SIZE,
|
||||
toggle: true,
|
||||
onClick: () => this.setAnchor(anchor),
|
||||
});
|
||||
this.anchors.push({ anchor, button });
|
||||
row.add(button);
|
||||
}
|
||||
grid.add(row);
|
||||
}
|
||||
this.content.add(this.formRow(this.fieldLabel('Anchor'), grid));
|
||||
this.setAnchor(this.anchor);
|
||||
|
||||
this.confirm = this.addButtons('Resize', 'primary', () =>
|
||||
onConfirm(this.newWidth, this.newHeight, this.anchor),
|
||||
);
|
||||
}
|
||||
|
||||
setAnchor(anchor: ResizeAnchor): void {
|
||||
this.anchor = anchor;
|
||||
for (const { anchor: a, button } of this.anchors) button.setToggled(a.x === anchor.x && a.y === anchor.y);
|
||||
}
|
||||
|
||||
/** What the dialog would resize to – the check scripts read this. */
|
||||
debugInfo(): { width: number; height: number; anchor: ResizeAnchor } {
|
||||
return { width: this.newWidth, height: this.newHeight, anchor: { ...this.anchor } };
|
||||
}
|
||||
|
||||
/** Where the anchor button for (x, y) sits on screen – the check scripts click it. */
|
||||
anchorButton(x: 0 | 0.5 | 1, y: 0 | 0.5 | 1): { x: number; y: number } | null {
|
||||
const found = this.anchors.find((a) => a.anchor.x === x && a.anchor.y === y);
|
||||
if (!found) return null;
|
||||
const p = found.button.getGlobalPosition();
|
||||
return { x: p.x + found.button.w / 2, y: p.y + found.button.h / 2 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import type { LoadedProject, ProjectStore, ProjectSummary } from '../../core/store/ProjectStore';
|
||||
import type { CelUploader, PixelCels } from '../../viewport/PixelCels';
|
||||
|
||||
/**
|
||||
* A project store that uploads the painted cels a project names before it saves the project (#130,
|
||||
* answer 1). The order is the point: a saved project must never name a cel the server lacks, or
|
||||
* the next load draws a hole. A failed upload fails the save, and autosave reports it like any other.
|
||||
*/
|
||||
export class CelUploadStore implements ProjectStore {
|
||||
constructor(
|
||||
private readonly inner: ProjectStore,
|
||||
private readonly cels: PixelCels,
|
||||
private readonly uploader: CelUploader,
|
||||
) {}
|
||||
|
||||
list(): Promise<ProjectSummary[]> {
|
||||
return this.inner.list();
|
||||
}
|
||||
|
||||
load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
|
||||
return this.inner.load(id, prepare);
|
||||
}
|
||||
|
||||
async save(project: Project, revision: number): Promise<number> {
|
||||
await this.cels.upload(project, this.uploader);
|
||||
return this.inner.save(project, revision);
|
||||
}
|
||||
|
||||
create(name: string): Promise<LoadedProject> {
|
||||
return this.inner.create(name);
|
||||
}
|
||||
|
||||
delete(id: string): Promise<void> {
|
||||
return this.inner.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* What the side panel of the pixel art tab does to an asset (#130). The layer and frame lists are
|
||||
* 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 { 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 { type CelPixels, emptyCel } from '../../core/model/pixelCel';
|
||||
import { mergeCels, type ResizeAnchor, resizeCel } from '../../core/model/pixelDraw';
|
||||
import {
|
||||
type PixelArtState,
|
||||
withCanvasSize,
|
||||
withFrameAdded,
|
||||
withFrameMoved,
|
||||
withFrameRemoved,
|
||||
withLayerAdded,
|
||||
withLayerChanged,
|
||||
withLayerMergedDown,
|
||||
withLayerMoved,
|
||||
withLayerRemoved,
|
||||
} from '../../core/model/pixelEdit';
|
||||
import { pixelCels } from '../../viewport/PixelCels';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
function edit(
|
||||
state: EditorState,
|
||||
asset: PixelArtAsset,
|
||||
label: string,
|
||||
run: (s: PixelArtState) => PixelArtState,
|
||||
): boolean {
|
||||
return state.execute(new EditPixelArtCommand(asset.id, label, run));
|
||||
}
|
||||
|
||||
/** Every cel the asset names, read; null while any of them is still loading. */
|
||||
function loadedCels(asset: PixelArtAsset): Map<string, CelPixels> | null {
|
||||
const cels = new Map<string, CelPixels>();
|
||||
for (const file of celFilesOf(asset)) {
|
||||
const cel = pixelCels.read(file);
|
||||
if (cel === undefined) return null;
|
||||
cels.set(file, cel ?? emptyCel(asset.width, asset.height));
|
||||
}
|
||||
return cels;
|
||||
}
|
||||
|
||||
const STILL_LOADING = 'The pixels are still loading – try again in a moment.';
|
||||
|
||||
/** The new layer's id, so the tab can go on painting on it. */
|
||||
export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: string): string {
|
||||
const id = newId();
|
||||
return edit(state, asset, 'Add layer', (s) => withLayerAdded(s, id, aboveId)) ? id : '';
|
||||
}
|
||||
|
||||
export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
|
||||
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
|
||||
}
|
||||
|
||||
export function renameLayer(
|
||||
state: EditorState,
|
||||
asset: PixelArtAsset,
|
||||
layerId: string,
|
||||
name: string,
|
||||
): boolean {
|
||||
return edit(state, asset, 'Rename layer', (s) => withLayerChanged(s, layerId, { name }));
|
||||
}
|
||||
|
||||
export function setLayerHidden(
|
||||
state: EditorState,
|
||||
asset: PixelArtAsset,
|
||||
layerId: string,
|
||||
hidden: boolean,
|
||||
): boolean {
|
||||
return edit(state, asset, hidden ? 'Hide layer' : 'Show layer', (s) =>
|
||||
withLayerChanged(s, layerId, { hidden }),
|
||||
);
|
||||
}
|
||||
|
||||
export function setLayerOpacity(
|
||||
state: EditorState,
|
||||
asset: PixelArtAsset,
|
||||
layerId: string,
|
||||
opacity: number,
|
||||
): boolean {
|
||||
return edit(state, asset, 'Layer opacity', (s) => withLayerChanged(s, layerId, { opacity }));
|
||||
}
|
||||
|
||||
/** Up is towards the top of the drawing order, the end of the list. */
|
||||
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, step: 1 | -1): boolean {
|
||||
const index = asset.layers.findIndex((layer) => layer.id === layerId);
|
||||
return edit(state, asset, step > 0 ? 'Move layer up' : 'Move layer down', (s) =>
|
||||
withLayerMoved(s, layerId, index + step),
|
||||
);
|
||||
}
|
||||
|
||||
/** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */
|
||||
export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
|
||||
const index = asset.layers.findIndex((layer) => layer.id === layerId);
|
||||
const lower = asset.layers[index - 1];
|
||||
if (!lower) {
|
||||
state.setNotice('There is no layer below to merge into.');
|
||||
return false;
|
||||
}
|
||||
const cels = loadedCels(asset);
|
||||
if (!cels) {
|
||||
state.setNotice(STILL_LOADING);
|
||||
return false;
|
||||
}
|
||||
const empty = emptyCel(asset.width, asset.height);
|
||||
const merged: Record<string, string> = {};
|
||||
for (const frame of asset.frames) {
|
||||
const below = cels.get(celOf(frame, lower.id)) ?? empty;
|
||||
const above = cels.get(celOf(frame, layerId)) ?? empty;
|
||||
merged[frame.id] = pixelCels.put(mergeCels(below, above));
|
||||
}
|
||||
return edit(state, asset, 'Merge layer down', (s) => withLayerMergedDown(s, layerId, merged));
|
||||
}
|
||||
|
||||
/** A new frame after `afterId`: empty, or a copy of it. Returns its id. */
|
||||
export function addFrame(state: EditorState, asset: PixelArtAsset, afterId: string, copy: boolean): string {
|
||||
const id = newId();
|
||||
const done = edit(state, asset, copy ? 'Duplicate frame' : 'Add frame', (s) =>
|
||||
withFrameAdded(s, id, afterId, copy ? afterId : ''),
|
||||
);
|
||||
return done ? id : '';
|
||||
}
|
||||
|
||||
export function removeFrame(state: EditorState, asset: PixelArtAsset, frameId: string): boolean {
|
||||
return edit(state, asset, 'Delete frame', (s) => withFrameRemoved(s, frameId));
|
||||
}
|
||||
|
||||
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, step: 1 | -1): boolean {
|
||||
const index = asset.frames.findIndex((frame) => frame.id === frameId);
|
||||
return edit(state, asset, step > 0 ? 'Move frame later' : 'Move frame earlier', (s) =>
|
||||
withFrameMoved(s, frameId, index + step),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every cel moved onto a canvas of the new size (answer 3), in one undo step. */
|
||||
export function resizeCanvas(
|
||||
state: EditorState,
|
||||
asset: PixelArtAsset,
|
||||
width: number,
|
||||
height: number,
|
||||
anchor: ResizeAnchor,
|
||||
): boolean {
|
||||
if (width === asset.width && height === asset.height) return false;
|
||||
const cels = loadedCels(asset);
|
||||
if (!cels) {
|
||||
state.setNotice(STILL_LOADING);
|
||||
return false;
|
||||
}
|
||||
const resized: Record<string, string> = {};
|
||||
for (const [file, cel] of cels) resized[file] = pixelCels.put(resizeCel(cel, width, height, anchor));
|
||||
return edit(state, asset, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized));
|
||||
}
|
||||
|
||||
export function setPaletteColor(
|
||||
state: EditorState,
|
||||
palette: PaletteAsset,
|
||||
index: number,
|
||||
color: number,
|
||||
): boolean {
|
||||
if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false;
|
||||
const colors = [...palette.colors];
|
||||
colors[index - 1] = color;
|
||||
return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', colors));
|
||||
}
|
||||
|
||||
/** Appends a colour; returns its index, 0 when the palette is full. */
|
||||
export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number {
|
||||
if (palette.colors.length >= PALETTE_MAX) {
|
||||
state.setNotice(`A palette holds at most ${PALETTE_MAX} colours.`);
|
||||
return 0;
|
||||
}
|
||||
const colors = [...palette.colors, color];
|
||||
return state.execute(new SetPaletteColorsCommand(palette.id, 'Add colour', colors)) ? colors.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The view rules of the pixel art tab (#130) that need no Pixi: whole zoom steps, where the grid
|
||||
* starts, and which pixel a pointer is over – so they can be checked without a browser.
|
||||
*/
|
||||
import type { SheetView } from '../texture/sheetView';
|
||||
|
||||
/** The zoom steps (answer 9: whole steps from 1x to 32x). Doubling alone jumps too far in the middle. */
|
||||
export const ZOOM_STEPS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32] as const;
|
||||
/** From this zoom on the pixel grid is drawn; below it the lines would be denser than the pixels. */
|
||||
export const GRID_FROM_ZOOM = 8;
|
||||
|
||||
/** The largest step at which the canvas still fits the area, never below 1x. */
|
||||
export function fitZoom(canvasW: number, canvasH: number, areaW: number, areaH: number): number {
|
||||
let zoom: number = ZOOM_STEPS[0];
|
||||
for (const step of ZOOM_STEPS)
|
||||
if (canvasW * step <= areaW * 0.9 && canvasH * step <= areaH * 0.9) zoom = step;
|
||||
return zoom;
|
||||
}
|
||||
|
||||
/** The next step up (`direction` 1) or down (-1) from `zoom`; stays at the ends. */
|
||||
export function stepZoom(zoom: number, direction: 1 | -1): number {
|
||||
if (direction > 0) return ZOOM_STEPS.find((step) => step > zoom) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1]!;
|
||||
return [...ZOOM_STEPS].reverse().find((step) => step < zoom) ?? ZOOM_STEPS[0];
|
||||
}
|
||||
|
||||
/** The canvas centred in the area at a zoom, on whole screen pixels so the pixel edges stay sharp. */
|
||||
export function centredView(
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
areaW: number,
|
||||
areaH: number,
|
||||
zoom: number,
|
||||
): SheetView {
|
||||
return {
|
||||
panX: Math.round((areaW - canvasW * zoom) / 2),
|
||||
panY: Math.round((areaH - canvasH * zoom) / 2),
|
||||
zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pixel under a widget-local point – anywhere, inside the canvas or not. */
|
||||
export function pixelUnder(view: SheetView, localX: number, localY: number): { x: number; y: number } {
|
||||
return { x: Math.floor((localX - view.panX) / view.zoom), y: Math.floor((localY - view.panY) / view.zoom) };
|
||||
}
|
||||
Reference in new issue
Block a user