The tab headers are one TabHeader, the node board and the tile board pan and zoom through one helper, reordering tile pictures and tilemap layers goes through one moveItem, and a tilemap cell is one named type (#85)
game-maker / gate (push) Successful in 1m42s
game-maker / suite (push) Successful in 6m9s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 16:01:12 +02:00
1 parent c91c18f495
commit 6d7d0005cf
19 files changed
+187 -195

No files matched your search

+5 -5
View File
@@ -101,7 +101,7 @@ export interface TextureImage {
* result, which carries `bytes` and – since original names are kept – a `name` of its own. A
* spread would put the *file's* name where the asset's belongs and silently overrule the caller.
*/
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return {
id,
name,
@@ -147,8 +147,8 @@ export class CreateTextureCommand extends ImportFileCommand<TextureAsset> {
}
}
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
return { id, name, props: defaultsFor('font'), type: 'font', file };
function buildFontAsset(name: string, file: string): FontAsset {
return { id: newId(), name, props: defaultsFor('font'), type: 'font', file };
}
export class CreateFontCommand extends ImportFileCommand<FontAsset> {
@@ -157,8 +157,8 @@ export class CreateFontCommand extends ImportFileCommand<FontAsset> {
}
}
export function buildSoundAsset(name: string, file: string, id = newId()): SoundAsset {
return { id, name, props: defaultsFor('sound'), type: 'sound', file };
function buildSoundAsset(name: string, file: string): SoundAsset {
return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file };
}
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
+1 -1
View File
@@ -57,7 +57,7 @@ export function requireObjectRef(project: Project, holderId: string, objectId: s
* Rejects an object that only exists because a container template says so. Its structure – what
* exists, what it is parented to – is the template's business, not the instance's.
*/
export function requireStructural(project: Project, holder: ObjectTree, objectId: string): void {
function requireStructural(project: Project, holder: ObjectTree, objectId: string): void {
if (!isTemplateContent(holder, objectId)) return;
// Not resolvable at all is a plain "not found", which the caller's own lookup words better.
const object = objectRef(project, holder, objectId)?.object;
+4 -5
View File
@@ -5,6 +5,7 @@
*/
import type { Project } from '../model/Project';
import { resolvePicture } from '../model/texture';
import { moveItem } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireTile } from './require';
@@ -76,12 +77,10 @@ export class MoveTilePictureCommand implements Command {
private move(project: Project, from: number, to: number): void {
const asset = requireTile(project, this.assetId);
const picture = asset.pictures[from];
if (picture === undefined) throw new CommandError(`${asset.name} has no picture ${from + 1}`);
if (to < 0 || to >= asset.pictures.length)
if (asset.pictures[from] === undefined)
throw new CommandError(`${asset.name} has no picture ${from + 1}`);
if (!moveItem(asset.pictures, from, to))
throw new CommandError(`${asset.name} has no place ${to + 1} to move it to`);
asset.pictures.splice(from, 1);
asset.pictures.splice(to, 0, picture);
}
}
+13 -18
View File
@@ -12,6 +12,8 @@ import { apply } from '../model/matrix';
import type { Position } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import {
type Cell,
cellAt,
cellKey,
edgeCompensation,
edgeSizeKey,
@@ -26,15 +28,14 @@ import {
import type { Project } from '../model/Project';
import { resolveSceneObjectProps } from '../properties/resolve';
import { localMatrixOf, transformOfObject } from '../properties/transform';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { nextNumberedName } from './naming';
import { type Command, CommandError } from './Command';
import { requireTilemap, requireTilemapLayer } from './require';
import { ObjectEditCommand } from './sceneObjectCommands';
/** One cell as a stroke wants it: `tileId` empty erases. */
export interface CellEdit {
x: number;
y: number;
export interface CellEdit extends Cell {
tileId: string;
}
@@ -57,7 +58,7 @@ export class PaintTilesCommand implements Command {
/** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */
static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean {
return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.tileId);
return edits.some((edit) => cellAt(layer, edit.x, edit.y) !== edit.tileId);
}
execute(project: Project): void {
@@ -170,15 +171,14 @@ export class AddTilemapLayerCommand implements Command {
undo(project: Project): void {
const asset = requireTilemap(project, this.assetId);
asset.layers = asset.layers.filter((layer) => layer.id !== this.layerId);
asset.layers = removeById(asset.layers, this.layerId);
}
}
/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */
export class RemoveTilemapLayerCommand implements Command {
readonly label = 'Remove layer';
private removed: TilemapLayer | null = null;
private index = -1;
private removed: Removed<TilemapLayer> | undefined;
constructor(
private readonly assetId: string,
@@ -188,15 +188,13 @@ export class RemoveTilemapLayerCommand implements Command {
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer');
this.index = asset.layers.findIndex((layer) => layer.id === this.layerId);
const layer = asset.layers[this.index];
if (!layer) throw new CommandError(`Layer ${this.layerId} not found`);
this.removed = layer;
asset.layers.splice(this.index, 1);
const removed = removeFirst(asset.layers, (layer) => layer.id === this.layerId);
if (!removed) throw new CommandError(`Layer ${this.layerId} not found`);
this.removed = removed;
}
undo(project: Project): void {
if (this.removed) requireTilemap(project, this.assetId).layers.splice(this.index, 0, this.removed);
if (this.removed) restoreAt(requireTilemap(project, this.assetId).layers, [this.removed]);
}
}
@@ -219,11 +217,8 @@ export class MoveTilemapLayerCommand implements Command {
}
private move(project: Project, from: number, to: number): void {
const asset = requireTilemap(project, this.assetId);
const layer = asset.layers[from];
if (!layer || to < 0 || to >= asset.layers.length) throw new CommandError('No such layer');
asset.layers.splice(from, 1);
asset.layers.splice(to, 0, layer);
if (!moveItem(requireTilemap(project, this.assetId).layers, from, to))
throw new CommandError('No such layer');
}
}
+3 -3
View File
@@ -26,14 +26,14 @@ export const TILE_COLLISION_SCALE_MIN = 0.1;
export const TILE_COLLISION_SCALE_MAX = 2;
/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */
export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
export type TilePlayback = (typeof TILE_PLAYBACKS)[number];
/** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */
export const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4 };
const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4 };
/** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */
export const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4 };
const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4 };
export function tileKind(asset: TileAsset): TileKind {
const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none');
+13 -19
View File
@@ -49,11 +49,17 @@ export function tilemapSize(props: PropertyBag): { w: number; h: number } {
return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile };
}
/** A cell of the grid by column and row; `0,0` is the top-left corner. */
export interface Cell {
x: number;
y: number;
}
export function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
export function parseCellKey(key: string): { x: number; y: number } | null {
export function parseCellKey(key: string): Cell | null {
const [x, y] = key.split(',').map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null;
}
@@ -152,7 +158,7 @@ export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
*/
export function cellAtPoint(props: PropertyBag, x: number, y: number): { x: number; y: number } {
export function cellAtPoint(props: PropertyBag, x: number, y: number): Cell {
const tile = tilemapTileSize(props);
const { w, h } = tilemapSize(props);
return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) };
@@ -214,12 +220,8 @@ export function edgeCompensation(
}
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
export function cellsInRect(
props: PropertyBag,
a: { x: number; y: number },
b: { x: number; y: number },
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
export function cellsInRect(props: PropertyBag, a: Cell, b: Cell): Cell[] {
const cells: Cell[] = [];
const x0 = Math.max(0, Math.min(a.x, b.x));
const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x));
const y0 = Math.max(0, Math.min(a.y, b.y));
@@ -234,12 +236,7 @@ export function cellsInRect(
* count, or a fill would leak through the gap between two corners. Filling with the tile already
* there changes nothing, so that returns no cells.
*/
export function floodFill(
props: PropertyBag,
layer: TilemapLayer,
start: { x: number; y: number },
tileId: string,
): { x: number; y: number }[] {
export function floodFill(props: PropertyBag, layer: TilemapLayer, start: Cell, tileId: string): Cell[] {
if (!inGrid(props, start.x, start.y)) return [];
const from = cellAt(layer, start.x, start.y);
if (from === tileId) return [];
@@ -269,13 +266,10 @@ export function floodFill(
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
* this a fast drag would paint dots instead of a line.
*/
export function cellsAlong(
from: { x: number; y: number },
to: { x: number; y: number },
): { x: number; y: number }[] {
export function cellsAlong(from: Cell, to: Cell): Cell[] {
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
if (steps === 0) return [{ ...to }];
const cells: { x: number; y: number }[] = [];
const cells: Cell[] = [];
for (let i = 1; i <= steps; i++)
cells.push({
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
+1 -4
View File
@@ -16,10 +16,7 @@ export interface SceneObjectRef {
object: SceneObject;
}
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
): SceneObjectRef[] {
function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
+8
View File
@@ -25,6 +25,14 @@ export function restoreAt<T>(list: T[], removed: readonly Removed<T>[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item);
}
/** In place; `false` means `from` or `to` is no place in the list, and nothing moved. */
export function moveItem<T>(list: T[], from: number, to: number): boolean {
const inside = (index: number) => Number.isInteger(index) && index >= 0 && index < list.length;
if (!inside(from) || !inside(to)) return false;
list.splice(to, 0, ...list.splice(from, 1));
return true;
}
/** Returns a new list; unlike the helpers above this one does not mutate. */
export function removeById<T extends { id: string }>(list: readonly T[], id: string): T[] {
return list.filter((item) => item.id !== id);