Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m51s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 06:41:47 +02:00
1 parent 0607221423
commit 33a7b1b63e
30 files changed
+2060 -20

No files matched your search

+5 -2
View File
@@ -8,6 +8,9 @@ export interface TabPlace<T extends string> {
tab: T;
x: number;
right: number;
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
y: number;
h: number;
}
/**
@@ -84,8 +87,8 @@ export class TabStrip<T extends string> extends HStack {
for (const tab of this.order) {
const button = this.buttons.get(tab)!;
if (!button.visible) continue;
const x = button.toGlobal({ x: 0, y: 0 }).x;
out.push({ tab, x, right: x + button.w });
const at = button.toGlobal({ x: 0, y: 0 });
out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h });
}
return out;
}
+57 -1
View File
@@ -4,6 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview';
import { resolveObjects } from '../core/model/instance';
import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture';
import { findLayer } from '../core/model/tilemap';
import { effectiveScale, gameClock } from '../core/time';
import {
activeDrag,
@@ -28,7 +29,8 @@ import { currentFontPicker } from './font/FontPicker';
import { currentNodePalette } from './graph/NodePalette';
import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker';
import type { EditorState } from './state';
import { currentTilemapSection } from './inspector/tilemapSection';
import type { EditorState, TilemapTool } from './state';
/**
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
@@ -248,6 +250,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
locate,
locateAll,
inspectorField: fieldOf,
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
inspectorRect: () => rectOf(inspector),
inspectorInfo: () => inspector.debugInfo(),
textColor,
overlayTexts,
@@ -523,6 +527,58 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
pictureRowAt: (index: number) => editor.autotileEditor.strip.list.rowGlobal(String(index)),
fitAt: () => centreOf(editor.autotileEditor.fitButton),
},
tilemap: {
/** What the scene view painted a map with, and on which layer (#128). */
paint: () => ({ ...state.tilemapPaint }),
/** The cells of one layer as the model holds them: `"x,y"` to autotile id. */
cells: (assetId: string, layerId: string) => {
const asset = findAssetOf(state.project, assetId, 'tilemap');
const layer = asset && findLayer(asset, layerId);
return layer ? { ...layer.cells } : null;
},
layers: (assetId: string) =>
findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({
id: layer.id,
name: layer.name,
hidden: layer.hidden === true,
cells: Object.keys(layer.cells).length,
})) ?? null,
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
info: (objectId: string) => viewport.tilemapInfo(objectId),
gameInfo: (objectId: string) => gameView.tilemapInfo(objectId),
/** Where the widgets of the inspector section sit: their texts are on screen elsewhere too. */
toolAt: (tool: string) => {
const button = currentTilemapSection()?.tools.get(tool as TilemapTool);
return button ? centreOf(button) : null;
},
autotileAt: (assetId: string) => {
const button = currentTilemapSection()?.autotiles.get(assetId);
return button ? centreOf(button) : null;
},
addLayerAt: () => {
const button = currentTilemapSection()?.addLayer;
return button ? centreOf(button) : null;
},
/** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */
layerRowAt: (index: number) => {
const row = currentTilemapSection()?.layers[index];
return row
? {
id: row.id,
// `y` is the row's own line, so a check can tell whether it is inside the panel.
y: centreOf(row.name).y,
name: centreOf(row.name),
visible: centreOf(row.visible),
up: centreOf(row.up),
down: centreOf(row.down),
remove: centreOf(row.remove),
}
: null;
},
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y),
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.picker.w,
+1 -1
View File
@@ -105,7 +105,7 @@ export class Inspector extends Panel {
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
}
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
+8 -4
View File
@@ -44,9 +44,10 @@ import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state';
import type { EditorState, ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { buildTilemapSection, tilemapSignature } from './tilemapSection';
import { overrideValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
@@ -62,13 +63,14 @@ export function objectRows(project: Project, asset: Asset, object: SceneObject):
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
}
/** Structure-defining facts of an object selection: parent, template link and schema. */
/** Structure-defining facts of an object selection: parent, template link, schema and painting. */
export function objectSignature(
project: Project,
state: EditorState,
holderId: string,
objectIds: readonly string[],
objectId: string,
): string {
const project = state.project;
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const holder = findObjectHolder(project, holderId);
const view = holder && resolvedHolder(project, holder);
@@ -81,7 +83,8 @@ export function objectSignature(
? 'instance'
: '';
const rows = schemaForObject(project, asset, object);
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',');
const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : '';
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting;
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -204,6 +207,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
markObjectRows(host, object, asset, target);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type === 'tilemap') buildTilemapSection(host, asset);
}
function assetLink(host: InspectorHost, label: string, asset: Asset): void {
+152
View File
@@ -0,0 +1,152 @@
/**
* The painting side of a placed tilemap (#128): which layer is painted on, with which autotile and
* with which tool. It hangs below the ordinary rows of the object, the way a container's content
* section does – the grid size and the cell size are plain properties and are shown above.
*
* Nothing here belongs to the model except the layers themselves: tool, autotile and layer are
* editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with.
*/
import {
AddTilemapLayerCommand,
MoveTilemapLayerCommand,
RemoveTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
} from '../../core/commands/tilemapCommands';
import type { TilemapAsset } from '../../core/model/Asset';
import { isLayerVisible } from '../../core/model/tilemap';
import { Button, Checkbox, HStack, Theme } from '../../ui';
import type { EditorState, TilemapTool } from '../state';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [
{ tool: 'select', label: 'Select', hint: 'Drag the map to move it, as any other object.' },
{ tool: 'brush', label: 'Brush', hint: 'Drag over the map to paint a line of cells.' },
{ tool: 'erase', label: 'Erase', hint: 'Drag over the map to clear cells.' },
{ tool: 'rect', label: 'Rect', hint: 'Drag to fill the box you span.' },
];
/**
* The layer that is painted on. The one chosen in the inspector when it still exists, otherwise
* the topmost – so a fresh selection can be painted on without choosing anything first.
*/
export function paintedLayerId(state: EditorState, asset: TilemapAsset): string {
const chosen = state.tilemapPaint.layerId;
if (asset.layers.some((layer) => layer.id === chosen)) return chosen;
return asset.layers[asset.layers.length - 1]?.id ?? '';
}
/**
* The widgets of the section as they were last built. Every one of them carries an icon or a name
* that is on screen elsewhere too – the autotile's row in the asset panel, the layer's name – so a
* check cannot find them by their text; this is what it clicks instead.
*/
export interface TilemapSectionWidgets {
tools: Map<TilemapTool, Button>;
autotiles: Map<string, Button>;
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
addLayer: Button;
}
let built: TilemapSectionWidgets | null = null;
export function currentTilemapSection(): TilemapSectionWidgets | null {
return built;
}
export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void {
const { state } = host;
const paint = state.tilemapPaint;
const widgets: TilemapSectionWidgets = {
tools: new Map(),
autotiles: new Map(),
layers: [],
addLayer: new Button('Add layer', {
onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)),
}),
};
built = widgets;
addTitle(host, 'PAINTING');
const tools = new HStack({ gap: Theme.gap });
for (const { tool, label } of TOOLS) {
const button = new Button(label, { onClick: () => state.setTilemapPaint({ tool }) });
button.toggled = paint.tool === tool;
widgets.tools.set(tool, button);
tools.add(button, 1);
}
host.body.add(tools);
addNote(host, TOOLS.find((t) => t.tool === paint.tool)?.hint ?? '', 'small');
addNote(host, 'Shift-drag still pans the view.', 'small');
addTitle(host, 'AUTOTILES');
const autotiles = state.project.assets.filter((a) => a.type === 'autotile');
if (autotiles.length === 0) addNote(host, 'The project has no autotile yet.');
for (const autotile of autotiles) {
const button = new Button(autotile.name, {
align: 'left',
// Choosing a tile is how one starts painting, so it reaches for the brush by itself.
onClick: () =>
state.setTilemapPaint({
autotileId: autotile.id,
tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool,
}),
});
button.toggled = paint.autotileId === autotile.id;
widgets.autotiles.set(autotile.id, button);
host.body.add(button);
}
if (autotiles.length > 0 && !autotiles.some((a) => a.id === paint.autotileId))
addNote(host, 'Choose an autotile to paint with.', 'small');
addTitle(host, 'LAYERS');
addNote(host, 'The last layer is drawn on top.', 'small');
const painted = paintedLayerId(state, asset);
// Bottom-up on screen, as in every layer list: the topmost row is the one drawn last.
for (let index = asset.layers.length - 1; index >= 0; index--) {
const layer = asset.layers[index]!;
const row = new HStack({ gap: Theme.gap });
const visible = new Checkbox(isLayerVisible(layer));
visible.onChange = (checked) =>
state.execute(new SetTilemapLayerHiddenCommand(asset.id, layer.id, !checked));
row.add(visible);
const choose = new Button(layer.name, {
align: 'left',
onClick: () => state.setTilemapPaint({ layerId: layer.id }),
});
choose.toggled = layer.id === painted;
row.add(choose, 1);
const up = new Button('↑', {
variant: 'ghost',
width: 24,
onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index + 1)),
});
const down = new Button('↓', {
variant: 'ghost',
width: 24,
onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index - 1)),
});
const remove = new Button('✕', {
variant: 'ghost',
width: 24,
onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, layer.id)),
});
row.add(up);
row.add(down);
row.add(remove);
widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove });
host.body.add(row);
}
addGap(host);
host.body.add(widgets.addLayer);
}
/**
* What the section is built from, for the inspector's rebuild check: the layers and what is
* painted with. The cells themselves are deliberately absent – a stroke must not rebuild the
* panel under the pointer that is drawing it.
*/
export function tilemapSignature(state: EditorState, asset: TilemapAsset): string {
const paint = state.tilemapPaint;
const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(',');
return `${paint.tool}|${paint.autotileId}|${paintedLayerId(state, asset)}|${layers}`;
}
+25
View File
@@ -139,6 +139,19 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
}
/**
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
* The other three paint: brush the chosen autotile cell by cell, erase clears, rect fills the box
* a drag spans. Painting is a mode on purpose; a map one cannot move would be worse than a click.
*/
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect';
export interface TilemapPaint {
tool: TilemapTool;
autotileId: string;
layerId: string;
}
export type EditorMode = 'edit' | 'play';
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
@@ -184,6 +197,12 @@ export class EditorState {
notice = '';
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
keyClipboard: KeyClipboard | null = null;
/**
* What the scene view paints a tilemap with (#128): the tool, the autotile and the layer it goes
* on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of
* working rather than something the project remembers.
*/
tilemapPaint: TilemapPaint = { tool: 'select', autotileId: '', layerId: '' };
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;
@@ -603,6 +622,12 @@ export class EditorState {
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
* for the play status lines, which state a mode rather than report an event.
*/
/** Changes what the scene view paints with; the inspector marks it and the view repaints. */
setTilemapPaint(patch: Partial<TilemapPaint>): void {
this.tilemapPaint = { ...this.tilemapPaint, ...patch };
this.emit('selection');
}
setNotice(message: string, level: NoticeLevel = 'info'): void {
this.notice = message;
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
+227 -3
View File
@@ -6,16 +6,38 @@
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
import { hasSceneIcon } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix';
import {
apply as applyPoint,
applyVector,
type Extent,
invert,
linearOf,
type Mat,
} from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
import {
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
findLayer,
inGrid,
tilemapColumns,
tilemapRows,
tilemapTileSize,
} from '../../core/model/tilemap';
import { footprintCorners } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { type EditorState, objectSelection } from '../state';
import { type EditorState, objectSelection, type TilemapTool } from '../state';
import { paintedLayerId } from '../inspector/tilemapSection';
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import { dashedLine, drawSourceIcon } from '../../viewport/symbols';
@@ -45,6 +67,26 @@ interface DropPreview {
rejection: string;
}
/**
* A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote
* into them – the same trick a move drag uses: the model shows the stroke at once, and on release
* it is put back so the command records it as one undo step.
*/
interface PaintStroke {
holderId: string;
objectId: string;
assetId: string;
layerId: string;
tool: TilemapTool;
autotileId: string;
edits: CellEdit[];
previous: Map<string, string>;
from: Vec2;
last: Vec2 | null;
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
to: Vec2;
}
/** A rubber-band selection in progress, in viewport-local pixels. */
interface Marquee {
start: Vec2;
@@ -74,6 +116,7 @@ export class SceneViewport extends FreeViewport {
private readonly labels = new Map<string, Text>();
private readonly icons = new Map<string, Graphics>();
private dragging: ObjectDrag | null = null;
private stroke: PaintStroke | null = null;
private marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null;
private lastGizmos: GizmoInfo[] = [];
@@ -162,6 +205,19 @@ export class SceneViewport extends FreeViewport {
return;
}
if (this.panTo(e)) return;
if (this.stroke) {
const item = this.lastItems.find((i) => i.id === this.stroke!.objectId);
if (!item) return;
const at = this.cellUnder(item, e);
if (this.stroke.last && at.x === this.stroke.last.x && at.y === this.stroke.last.y) return;
const from = this.stroke.last ?? this.stroke.from;
this.stroke.last = at;
this.stroke.to = at;
// A pointer jumps between events, so the cells in between are filled in as well.
if (this.stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at));
this.redraw();
return;
}
if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
@@ -239,6 +295,10 @@ export class SceneViewport extends FreeViewport {
return;
}
this.endPan();
if (this.stroke) {
this.endStroke();
return;
}
const marquee = this.marquee;
this.marquee = null;
if (marquee) {
@@ -263,6 +323,120 @@ export class SceneViewport extends FreeViewport {
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
}
/**
* The tilemap a click paints on: the primary selection, placed here, with a painting tool
* chosen. Anything else – another object, the select tool, a modifier held – leaves the view
* doing what it always did.
*/
private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null {
const paint = this.state.tilemapPaint;
if (paint.tool === 'select') return null;
const s = this.state.selection;
if (s?.kind !== 'object' || s.objectId !== objectId) return null;
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || item.type !== 'tilemap') return null;
const object = findObject(this.resolved() ?? { objects: [] }, objectId);
const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap');
if (!asset) return null;
const layerId = paintedLayerId(this.state, asset);
return layerId ? { item, asset, layerId } : null;
}
/** The cell under a pointer position, in the tilemap's own grid. */
private cellUnder(item: DrawItem, e: FederatedPointerEvent): Vec2 {
const local = this.toLocal(e.global);
const world = screenToWorld(this.currentView(), local.x, local.y);
const inMap = applyPoint(invert(item.m), world);
return cellAtPoint(item.props, inMap.x, inMap.y);
}
private startStroke(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
holderId: string,
e: FederatedPointerEvent,
): boolean {
const paint = this.state.tilemapPaint;
const autotileId = paint.tool === 'erase' ? '' : paint.autotileId;
if (paint.tool !== 'erase' && !autotileId) {
this.state.setNotice('Choose an autotile to paint with.');
return false;
}
const at = this.cellUnder(target.item, e);
this.stroke = {
holderId,
objectId: target.item.id,
assetId: target.asset.id,
layerId: target.layerId,
tool: paint.tool,
autotileId,
edits: [],
previous: new Map(),
from: at,
last: null,
to: at,
};
// A rectangle only shows where it would go until the button comes up; the other two draw at once.
if (paint.tool !== 'rect') this.paintCells(target.item, [at]);
this.redraw();
return true;
}
private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined {
const asset = findAssetOf(this.state.project, stroke.assetId, 'tilemap');
return asset && findLayer(asset, stroke.layerId);
}
/** Writes cells into the model as a preview, remembering what they held. */
private paintCells(item: DrawItem, cells: readonly Vec2[]): void {
const stroke = this.stroke;
const layer = stroke && this.strokeLayer(stroke);
if (!stroke || !layer) return;
let changed = false;
for (const at of cells) {
if (!inGrid(item.props, at.x, at.y)) continue;
const key = cellKey(at.x, at.y);
if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? '');
if ((layer.cells[key] ?? '') === stroke.autotileId) continue;
stroke.edits.push({ x: at.x, y: at.y, autotileId: stroke.autotileId });
if (stroke.autotileId) layer.cells[key] = stroke.autotileId;
else delete layer.cells[key];
changed = true;
}
if (changed) this.state.preview();
}
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
private endStroke(): void {
const stroke = this.stroke;
this.stroke = null;
if (!stroke) return;
const layer = this.strokeLayer(stroke);
if (layer)
for (const [key, value] of stroke.previous) {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
const item = this.lastItems.find((i) => i.id === stroke.objectId);
const edits =
stroke.tool === 'rect' && item
? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({
...at,
autotileId: stroke.autotileId,
}))
: stroke.edits;
if (edits.length > 0)
this.state.execute(
new PaintTilesCommand(
stroke.assetId,
stroke.layerId,
edits,
stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles',
),
);
else this.state.preview();
this.redraw();
}
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const holder = this.resolved();
@@ -273,6 +447,9 @@ export class SceneViewport extends FreeViewport {
this.state.toggleObject(holder.id, objectId);
return;
}
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
const target = this.paintTargetOf(objectId);
if (target && this.startStroke(target, holder.id, e)) return;
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(holder.id, [objectId]));
@@ -447,6 +624,52 @@ export class SceneViewport extends FreeViewport {
.stroke({ width: 1, color });
}
/**
* While a painting tool is chosen, the selected tilemap shows its cells – without the grid one
* paints blind – and a rectangle drag shows the box it would fill.
*/
private drawPaintOverlay(items: DrawItem[], view: View): void {
const s = this.state.selection;
if (this.state.tilemapPaint.tool === 'select' || s?.kind !== 'object') return;
const item = items.find((i) => i.id === s.objectId && i.type === 'tilemap');
if (!item) return;
const tile = tilemapTileSize(item.props);
const columns = tilemapColumns(item.props);
const rows = tilemapRows(item.props);
const corner = (x: number, y: number): Vec2 => {
const at = cellOrigin(item.props, x, y);
return worldToScreen(
view,
item.m.tx + at.x * item.m.a + at.y * item.m.c,
item.m.ty + at.x * item.m.b + at.y * item.m.d,
);
};
// Below this the lines are noise, exactly as the scene grid is (see `syncGrid`).
if (tile * view.scale >= MIN_GRID_STEP) {
for (let x = 0; x <= columns; x++) {
const a = corner(x, 0);
const b = corner(x, rows);
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
}
for (let y = 0; y <= rows; y++) {
const a = corner(0, y);
const b = corner(columns, y);
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
}
this.gizmos.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.25 });
}
const stroke = this.stroke;
if (!stroke || stroke.tool !== 'rect') return;
const x0 = Math.min(stroke.from.x, stroke.to.x);
const y0 = Math.min(stroke.from.y, stroke.to.y);
const x1 = Math.max(stroke.from.x, stroke.to.x) + 1;
const y1 = Math.max(stroke.from.y, stroke.to.y) + 1;
this.gizmos
.poly([corner(x0, y0), corner(x1, y0), corner(x1, y1), corner(x0, y1)], true)
.fill({ color: Theme.colors.accent, alpha: 0.2 })
.stroke({ width: 2, color: Theme.colors.accent });
}
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y));
}
@@ -476,6 +699,7 @@ export class SceneViewport extends FreeViewport {
}
}
this.drawDropPreview(view);
this.drawPaintOverlay(items, view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
+52
View File
@@ -29,7 +29,9 @@ import {
placeNode,
syncSpriteMesh,
} from '../../viewport/symbols';
import { cellCentre, TilemapField } from '../../viewport/tilemap';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { gameClock } from '../../core/time';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
export interface Frame {
@@ -53,6 +55,8 @@ export abstract class WorldViewport extends Widget {
private readonly sprites = new Map<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */
private readonly fields = new Map<string, ParticleField>();
/** The meshes of each placed tilemap, one container per map. */
private readonly tilemaps = new Map<string, TilemapField>();
private visibleCount = 0;
private animated = false;
protected lastItems: DrawItem[] = [];
@@ -245,6 +249,7 @@ export abstract class WorldViewport extends Widget {
this.syncText(item, onScreen, view.scale);
this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen, shaders);
this.syncTilemap(item, onScreen);
if (!onScreen) continue;
this.visibleCount++;
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m)));
@@ -259,6 +264,8 @@ export abstract class WorldViewport extends Widget {
this.sprites.delete(id);
this.fields.get(id)?.destroy();
this.fields.delete(id);
this.tilemaps.get(id)?.destroy();
this.tilemaps.delete(id);
node.destroy();
this.nodes.delete(id);
this.disposeDecorations(id);
@@ -284,6 +291,8 @@ export abstract class WorldViewport extends Widget {
if (text) this.objects.addChild(text);
const field = this.fields.get(item.id);
if (field) this.objects.addChild(field.container);
const tilemap = this.tilemaps.get(item.id);
if (tilemap) this.objects.addChild(tilemap.container);
}
}
@@ -323,6 +332,49 @@ export abstract class WorldViewport extends Widget {
);
}
/**
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
* autotile is on it, the same way a Time shader does.
*/
private syncTilemap(item: DrawItem, onScreen: boolean): void {
let field = this.tilemaps.get(item.id);
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
if (field) field.container.visible = false;
return;
}
if (!field) {
field = new TilemapField();
this.tilemaps.set(item.id, field);
this.objects.addChild(field.container);
}
field.container.visible = true;
field.sync(item, textureLibrary, gameClock.now);
if (field.animated) this.animated = true;
}
/** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */
cellScreen(objectId: string, x: number, y: number): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!view || !item || item.type !== 'tilemap') return null;
const centre = cellCentre(item, x, y);
const local = worldToScreen(view, centre.x, centre.y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
const field = this.tilemaps.get(objectId);
const item = this.lastItems.find((i) => i.id === objectId);
if (!field || !field.container.visible || !item?.tilemap) return null;
return {
drawCalls: field.drawCalls,
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
layers: item.tilemap.layers.length,
};
}
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) {