From f53279d06557fbeea2cef129e6abaf21df9629b1 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 10:56:32 +0200 Subject: [PATCH] The active tilemap layer can be renamed from the painting section, and a Show setting fades or hides the other layers in the scene view while the tilemap is selected (#138) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/commands/tilemapCommands.ts | 4 +- src/editor/debugHook.ts | 9 +++ src/editor/inspector/tilemapSection.ts | 51 ++++++++++-- src/editor/state.ts | 10 ++- src/editor/viewport/SceneViewport.ts | 16 ++++ src/editor/viewport/WorldViewport.ts | 12 ++- src/viewport/tilemap.ts | 25 +++++- tests/core/tilemap.test.ts | 24 ++++++ tools/e2e/40-tilemap.mjs | 108 ++++++++++++++++++++++++- 10 files changed, 248 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f2f6eb1e..f775f298 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1023 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1027 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 4cad325e..7f028f96 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -147,8 +147,10 @@ export class RenameTilemapLayerCommand implements Command { execute(project: Project): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); + const name = this.name.trim(); + if (!name) throw new CommandError('A layer needs a name'); this.previous = layer.name; - layer.name = this.name; + layer.name = name; } undo(project: Project): void { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 276b226b..4f45bee4 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -571,6 +571,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const button = currentTilemapSection()?.addLayer; return button ? centreOf(button) : null; }, + /** The name field of the painted layer and the Show dropdown, with what they hold (#138). */ + layerNameAt: () => { + const field = currentTilemapSection()?.layerName; + return field ? { ...centreOf(field), value: field.value } : null; + }, + focusAt: () => { + const button = currentTilemapSection()?.focus; + return button ? { ...centreOf(button), label: button.text } : 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]; diff --git a/src/editor/inspector/tilemapSection.ts b/src/editor/inspector/tilemapSection.ts index 9af708af..8303a8a0 100644 --- a/src/editor/inspector/tilemapSection.ts +++ b/src/editor/inspector/tilemapSection.ts @@ -10,15 +10,23 @@ import { AddTilemapLayerCommand, MoveTilemapLayerCommand, RemoveTilemapLayerCommand, + RenameTilemapLayerCommand, 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 { findLayer, isLayerVisible } from '../../core/model/tilemap'; +import { Button, Checkbox, Dropdown, HStack, Menu, TextField, Theme } from '../../ui'; +import type { EditorState, TilemapFocus, TilemapTool } from '../state'; import { TilePalette } from '../tile/TilePalette'; +import { labelledRow } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; +const FOCUS: { focus: TilemapFocus; label: string }[] = [ + { focus: 'all', label: 'All layers' }, + { focus: 'dim', label: 'Others faded' }, + { focus: 'solo', label: 'Active layer only' }, +]; + 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.' }, @@ -46,6 +54,10 @@ export interface TilemapSectionWidgets { /** Null while the project has no tile. */ palette: TilePalette | null; layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; + /** The name of the layer painted on (#138). */ + layerName: TextField; + /** How the other layers are shown in the scene view (#138). */ + focus: Dropdown; addLayer: Button; } @@ -58,10 +70,31 @@ export function currentTilemapSection(): TilemapSectionWidgets | null { export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; + const painted = paintedLayerId(state, asset); + const paintedName = findLayer(asset, painted)?.name ?? ''; + const layerName: TextField = new TextField(paintedName, { + onChange: (name) => { + // The command rejects a blank name but cannot put the old text back into the field, + // so an empty one is not sent at all. + if (name.trim()) state.execute(new RenameTilemapLayerCommand(asset.id, painted, name)); + else layerName.setValue(paintedName); + }, + }); + const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', { + onClick: () => + new Menu( + FOCUS.map((f) => ({ + label: f.focus === paint.focus ? `● ${f.label}` : f.label, + onSelect: () => state.setTilemapPaint({ focus: f.focus }), + })), + ).openBelow(focus), + }); const widgets: TilemapSectionWidgets = { tools: new Map(), palette: null, layers: [], + layerName, + focus, addLayer: new Button('Add layer', { onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), }), @@ -98,7 +131,6 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v 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]!; @@ -134,6 +166,15 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove }); host.body.add(row); } + // Below the list rather than in it: a row is a button that picks the layer, and a text field + // in its place would take the click that chooses it. + const nameRow = labelledRow('Name').row; + nameRow.add(layerName, 1); + host.body.add(nameRow); + const showRow = labelledRow('Show').row; + showRow.add(focus, 1); + host.body.add(showRow); + addNote(host, 'Show only changes the scene view, not the game.', 'small'); addGap(host); host.body.add(widgets.addLayer); } @@ -152,5 +193,5 @@ export function tilemapSignature(state: EditorState, asset: TilemapAsset): strin const paint = state.tilemapPaint; const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); const tiles = tileIdsOf(state).join(','); - return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${layers}|${tiles}`; + return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${paint.focus}|${layers}|${tiles}`; } diff --git a/src/editor/state.ts b/src/editor/state.ts index 37a94c40..cd0c54be 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -146,10 +146,18 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p */ export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect'; +/** + * How the scene view shows the layers of the selected tilemap next to the one painted on (#138): + * all as they are, the others faded, or the painted one alone. A way of looking while painting, + * so it leaves the model – and with it the game – untouched. + */ +export type TilemapFocus = 'all' | 'dim' | 'solo'; + export interface TilemapPaint { tool: TilemapTool; tileId: string; layerId: string; + focus: TilemapFocus; } export type EditorMode = 'edit' | 'play'; @@ -202,7 +210,7 @@ export class EditorState { * 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', tileId: '', layerId: '' }; + tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' }; /** The real (saved) model and its undo stack while `project` is the play-mode copy. */ private edit: { project: Project; commands: CommandStack } | null = null; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 6f6dbc26..c96b0c21 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -42,6 +42,7 @@ 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 type { LayerFocus } from '../../viewport/tilemap'; import { dashedLine, drawSourceIcon, placeNode } from '../../viewport/symbols'; import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { FreeViewport } from './FreeViewport'; @@ -185,6 +186,21 @@ export class SceneViewport extends FreeViewport { return true; } + /** + * The selected tilemap shows its layers the way the inspector asks (#138), whatever the tool: + * the painted layer is worth seeing alone before the brush is taken up, too. Every other map, + * and this one once it is deselected, is drawn whole. + */ + protected override tilemapFocus(item: DrawItem): LayerFocus | null { + const mode = this.state.tilemapPaint.focus; + const s = this.state.selection; + if (mode === 'all' || item.type !== 'tilemap' || s?.kind !== 'object' || s.objectId !== item.id) + return null; + const object = findObject(this.resolved() ?? { objects: [] }, item.id); + const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap'); + return asset ? { layerId: paintedLayerId(this.state, asset), mode } : null; + } + private onPointerDown(e: FederatedPointerEvent): void { if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { this.startPan(e); diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index adc0fba4..4365441b 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -29,7 +29,7 @@ import { placeNode, syncSpriteMesh, } from '../../viewport/symbols'; -import { cellCentre, TilemapField } from '../../viewport/tilemap'; +import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { gameClock } from '../../core/time'; import { type View, worldBounds, worldToScreen } from '../../viewport/view'; @@ -348,10 +348,18 @@ export abstract class WorldViewport extends Widget { this.objects.addChild(field.container); } field.container.visible = true; - field.sync(item, textureLibrary, gameClock.now); + field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item)); if (field.animated) this.animated = true; } + /** + * Which layer of a tilemap is singled out and how (#138). The game view shows every map as the + * game does; only the scene view, where one paints, has a say. + */ + protected tilemapFocus(_item: DrawItem): LayerFocus | null { + return null; + } + /** 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(); diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index 11446909..28248a79 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -12,6 +12,21 @@ import { placeNode } from './symbols'; import type { TextureLibrary } from './TextureLibrary'; import { groupByTile, type TileSheet, tileMesh } from './tilemapMesh'; +/** The layer painted on and how the others are shown next to it (#138); null shows every layer. */ +export interface LayerFocus { + layerId: string; + mode: 'all' | 'dim' | 'solo'; +} + +/** How far the others fade in `dim`: still readable as a map, plainly not what the brush reaches. */ +export const DIMMED_LAYER_ALPHA = 0.3; + +/** How opaque one layer is drawn; 0 means it is not drawn at all. */ +export function layerAlpha(layerId: string, focus: LayerFocus | null): number { + if (!focus || focus.mode === 'all' || layerId === focus.layerId) return 1; + return focus.mode === 'dim' ? DIMMED_LAYER_ALPHA : 0; +} + export class TilemapField { readonly container = new Container(); private readonly meshes = new Map(); @@ -28,7 +43,7 @@ export class TilemapField { * `now` is the global clock, so every tilemap showing the same tile is on the same step – * animated water does not run out of phase between two maps. */ - sync(item: DrawItem, textures: TextureLibrary, now: number): void { + sync(item: DrawItem, textures: TextureLibrary, now: number, focus: LayerFocus | null = null): void { const draw = item.tilemap; this.animated = false; // The whole map sits under one node, so the object's own transform is applied once rather @@ -39,6 +54,8 @@ export class TilemapField { const drawn: Mesh[] = []; const alive = new Set(); for (const layer of draw?.layers ?? []) { + const alpha = layerAlpha(layer.id, focus); + if (alpha === 0) continue; for (const [tileId, tiles] of groupByTile(layer.tiles)) { const source = draw?.tiles.get(tileId); const picture = source && pictureNow(source, now); @@ -48,7 +65,11 @@ export class TilemapField { const key = `${layer.id}|${tileId}`; alive.add(key); const { positions, uvs, indices } = tileMesh(tiles, tile, origin, sheetOf(source, picture, texture)); - drawn.push(this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture)); + const mesh = this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture); + // Per mesh rather than per layer: the meshes of one layer never overlap, so fading each + // one looks the same as fading the layer as a whole. + mesh.alpha = alpha; + drawn.push(mesh); } } for (const [key, mesh] of this.meshes) { diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index b10e7bbe..cb001200 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -34,6 +34,7 @@ import { tilemapSize, } from '../../src/core/model/tilemap'; import { tilemapsUsing } from '../../src/core/model/usages'; +import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap'; import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh'; import { makeProject, roundTrip } from '../helpers'; @@ -318,6 +319,18 @@ describe('the layers of a tilemap', () => { expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']); }); + it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => { + const f = makeTilemap(); + f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground ')); + expect(f.assetOf().layers[0]!.name).toBe('Ground'); + expect(() => f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' '))).toThrow( + 'A layer needs a name', + ); + expect(f.assetOf().layers[0]!.name).toBe('Ground'); + f.stack.undo(); + expect(f.assetOf().layers[0]!.name).toBe('Layer 1'); + }); + it('keeps the last layer, because there has to be one to paint on', () => { const f = makeTilemap(); expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow( @@ -338,6 +351,17 @@ describe('the layers of a tilemap', () => { }); }); +describe('how the scene view shows the layers next to the painted one', () => { + it('draws all of them whole, fades the others or leaves them out', () => { + expect(layerAlpha('a', null)).toBe(1); + expect(layerAlpha('b', { layerId: 'a', mode: 'all' })).toBe(1); + expect(layerAlpha('a', { layerId: 'a', mode: 'dim' })).toBe(1); + expect(layerAlpha('b', { layerId: 'a', mode: 'dim' })).toBe(DIMMED_LAYER_ALPHA); + expect(layerAlpha('a', { layerId: 'a', mode: 'solo' })).toBe(1); + expect(layerAlpha('b', { layerId: 'a', mode: 'solo' })).toBe(0); + }); +}); + describe('a tilemap in the document', () => { it('survives a round trip with its layers and cells', () => { const f = makeTilemap(); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 4f320e73..12958f85 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -64,8 +64,31 @@ async function cellShows(t, x, y, quadrants) { return { ok: off.length === 0, detail: JSON.stringify({ read, want }) }; } +/** `over` laid at `alpha` on `under`, both `#rrggbb` as `pixelAt` reads them. */ +function blend(over, under, alpha) { + const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); + return `#${[1, 3, 5] + .map((i) => Math.round(channel(over, i) * alpha + channel(under, i) * (1 - alpha))) + .map((c) => c.toString(16).padStart(2, '0')) + .join('')}`; +} + export async function tilemap(t) { - const { check, json, evaluate, mutate, click, clickText, drag, press, shot, settle, undo, select } = t; + const { + check, + json, + evaluate, + mutate, + click, + clickText, + drag, + press, + typeText, + shot, + settle, + undo, + select, + } = t; const { saved, api, navigate, projectId } = t; await press('Escape'); @@ -283,6 +306,89 @@ export async function tilemap(t) { await undo(); await settle(); + // 40m. The painted layer is named in the field below the list, and Show singles it out in the + // scene view (#138). It sits here, not at the end, because here both layers exist: the upper + // one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. + const nameField = await inInspector(t, '__gm.tilemap.layerNameAt()'); + if (nameField) { + await click(nameField.x, nameField.y); + await press('a', { ctrl: true }); + await typeText('Roofs'); + await press('Enter'); + } + await settle(); + layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).id'); + const nameNow = await json('(__gm.tilemap.layerNameAt() || {}).value'); + check( + 'the name field renames the layer painted on, and the list shows the new name', + layers[1].name === 'Roofs' && + layers[0].name === 'Layer 1' && + renamedRow === layers[1].id && + nameNow === 'Roofs', + JSON.stringify({ layers, renamedRow, nameNow }), + ); + + // Pixels again, so the animation is cut down to one step for the reading, as in 40e. + await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); + await settle(); + const quarter = async (x, y) => (await cellQuarters(t, x, y))?.[0] ?? null; + const lowerWhole = await quarter(1, 1); + const emptyCell = await quarter(5, 3); + const upperWhole = await quarter(4, 0); + /** Chooses an entry of the Show dropdown: the topmost hit, which is the open menu. */ + const show = async (label) => { + const button = await inInspector(t, '__gm.tilemap.focusAt()'); + if (button) await click(button.x, button.y); + await settle(); + const entry = await t.locate(label, true); + await click(entry.x, entry.y); + await settle(); + }; + + await show('Others faded'); + const faded = await quarter(1, 1); + // The lower layer is laid over the background at 30 %, so its colour lands in between. + const mix = lowerWhole && emptyCell && blend(lowerWhole, emptyCell, 0.3); + const focusLabel = await json('(__gm.tilemap.focusAt() || {}).label'); + check( + 'Others faded draws the other layers at 30 % and the painted one whole', + Boolean(mix) && + colourDistance(faded, mix) <= 16 && + colourDistance(await quarter(4, 0), upperWhole) <= 12 && + focusLabel === 'Others faded', + JSON.stringify({ faded, mix, lowerWhole, emptyCell, focusLabel }), + ); + + await show('Active layer only'); + const solo = await quarter(1, 1); + const soloInfo = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); + check( + 'Active layer only leaves the other layers out and keeps the painted one', + colourDistance(solo, emptyCell) <= 12 && + colourDistance(await quarter(4, 0), upperWhole) <= 12 && + soloInfo.drawCalls === 1, + JSON.stringify({ solo, emptyCell, soloInfo }), + ); + + // Only the selected map is singled out; deselected it is drawn whole, and the model never changed. + await evaluate('__gm.state.select(null)'); + await settle(); + const deselected = await quarter(1, 1); + layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + check( + 'a deselected map is drawn whole again, and Show hid nothing in the model', + colourDistance(deselected, lowerWhole) <= 12 && layers.every((l) => !l.hidden), + JSON.stringify({ deselected, lowerWhole, layers }), + ); + await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); + await show('All layers'); + await mutate( + "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", + ); + await undo(); // the rename + await settle(); + // 40i. Back to Select: the map is moved again instead of painted on. await clickInInspector(t, '__gm.tilemap.toolAt("select")'); await settle();