/**
* LittleJS Level Editor
* - Paint the game's tile layers while it is paused, then keep playing with the changes
* - Press 0 while the debug overlay is open to edit, 0 again to play, or call levelEditor.open() and close()
* - Edits the Tiled map the game loaded, saves it back as Tiled JSON, and autosaves every change
* - Debug builds only, the release build has stubs for its names in engineRelease.js and none of its code
* @namespace Editor
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
// the level editor
/**
* @callback EditorPlayFromCallback - Puts the player at a world position, for the level editor's Play from mouse
* @param {Vector2} pos - Where to start playing
* @memberof Editor
*/
/**
* @callback EditorRestartCallback - Rebuilds the level from the map the level editor changed
* @memberof Editor
*/
/**
* @callback EditorTileCallback - What the game does when the level editor paints a tile
* @param {TileLayer} layer - The layer painted
* @param {Vector2} pos - The cell's position in the layer
* @param {number|undefined} tile - The tile painted, undefined when erased
* @memberof Editor
*/
/**
* The level editor, open it to pause the game and edit its level, close it to play on with the changes
* - One of it, levelEditor, 0 on the debug overlay opens and closes it too
* - In release builds levelEditor is a stub that never opens, and its hooks are never called
* @memberof Editor
* @example
* levelEditor.onRestart = ()=> loadLevel(); // adds a Restart button that rebuilds the level
* levelEditor.onTile = (layer, pos, tile)=> layer.setCollisionData(pos, tile === ladderTile ? -1 : tile ? 1 : 0);
*/
class LevelEditor
{
constructor()
{
/** @property {EditorTileCallback|undefined} - What the game does when the editor paints a tile, like
* setting its collision or its look the way the game does when it loads the level; without one, the
* collision layer gets collision 1 where there is a tile
* @type {EditorTileCallback|undefined} */
this.onTile = undefined;
/** @property {Function|undefined} - Rebuild the level from the map the editor changed, a Restart button
* calls it after switching to play; without one there is no Restart button
* @type {EditorRestartCallback|undefined} */
this.onRestart = undefined;
/** @property {Function|undefined} - Put the player at a world position; with it, the editor's Advanced
* section has Play from mouse, which starts play there, Escape at the mouse and Play at the view center
* @type {EditorPlayFromCallback|undefined} */
this.onPlayFrom = undefined;
/** @property {Array<number>|undefined} - The tiles the palette shows, in its order, for a sheet that also holds
* sprites and art that are not level tiles; undefined shows every tile of the sheet
* @type {Array<number>|undefined} */
this.paletteTiles = undefined;
/** @property {boolean|undefined} - Which level editor opens: true the 3D one, false the 2D one, undefined the
* 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin
* @type {boolean|undefined} */
this.use3D = undefined;
}
/** True while the editor is open, the game is paused under it
* @return {boolean} */
get isOpen() { return editorIsOpen; }
/** Open the editor, pausing the game; until close(), Escape (the debug key) switches between playing and editing */
open()
{
if (!editorSession)
editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
editorSession = true;
editorSetOpen(true);
}
/** Close the editor and end its session, the game carries on with the changes and Escape opens the debug
* overlay again */
close()
{
editorSession = false;
editorSetOpen(false);
}
}
/** The level editor, levelEditor.open() to edit the level, levelEditor.close() to play on with the changes
* @type {LevelEditor}
* @memberof Editor */
const levelEditor = new LevelEditor;
// if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
let editorIsOpen = false, editorSession = false;
// if switching to play puts the player at the mouse, and if the panel's Advanced section is shown
let editorPlayFromMouse = false, editorAdvanced = false;
// switch to play in the session, with the player at a position when Play from mouse is on and the game has a hook
function editorPlay(pos)
{
editorSetOpen(false);
editorPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
}
// the game's pause and camera from before the editor opened, handed back when it closes
let editorGameState;
// the editor's own view, so the game's camera is left where the game had it
let editorCameraPos = vec2(), editorCameraScale = 32;
// the map being edited, the selected object layer's or tile layer's, a level of objects alone has no tile layer
const editorRecord = ()=> (editorObjectLayer ?? editorLayer)?.record;
// the layer being painted, the cell under the mouse, the last cell of a stroke, the last cell a stamp was
// placed on, for a Shift line
let editorLayer, editorHover, editorLastCell, editorLastPlaced;
// the selected area, cells of the layer being edited from min to max, the right button's press while it is held,
// and a left press that only cleared a selection, which paints nothing until let go
let editorSelection, editorRightPress, editorLeftSpent;
// a left drag moving the selection: the layer, the cell pressed and how far it moved, the area and its tiles as
// they were, and with All Layers the ids of the objects in it, by object layer
let editorSelectionDrag;
// if the mouse is over the panel, and where it was last on the level, which is where the editor reads it while it is
// over the panel, so a drag held across it stops at its edge and paints nothing behind it
let editorMouseOnPanel = false, editorMouseScreen = vec2();
// if selection edits, Delete, Ctrl+C and Ctrl+X, act on every tile layer of the edited layer's map
let editorAllLayers = false;
// the grid drawn over the layer, G, and the list of keys in the panel, ?
let editorGrid = true, editorHelp = false;
// open or close the editor, the game is paused while it is open and carries on with the changes after
function editorSetOpen(open)
{
if (!debug || editorIsOpen === !!open) return;
editorIsOpen = !!open;
if (editorIsOpen)
{
editorGameState = {paused, cameraPos: cameraPos.copy(), cameraScale, cameraAngle};
editorCameraPos = cameraPos.copy(); // where the game is, the zoom stays the editor's
setPaused(true);
setDebugOverlay(false); // out of the way of the level
// the layer edited last, when it is still there, or the collision layer
const layers = editorLayers();
layers.includes(editorLayer) || (editorLayer = layers.find((layer)=> layer.live?.isSolid) ??
layers.filter((layer)=> !layer.isObjects).at(-1));
layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined :
layers.find((layer)=> layer.isObjects)); // a level of objects alone
}
else
{
editorStrokeEnd();
editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
editorObjectSelection.clear();
editorMouseOnPanel = false; // the panel hides, with no mouseleave
const state = editorGameState;
setPaused(state.paused);
setCameraPos(state.cameraPos);
setCameraScale(state.cameraScale);
setCameraAngle(state.cameraAngle);
}
}
// the editor's view, applied before it reads the mouse and before each render, since a game may move the
// camera from gameUpdatePost, which runs while paused
function editorApplyCamera()
{
setCameraPos(editorCameraPos);
setCameraScale(editorCameraScale);
setCameraAngle(0); // painting is on the grid as the level is laid out
}
///////////////////////////////////////////////////////////////////////////////
// tiles as Tiled gids
// a tile, its quarter turns and mirror as a Tiled gid, its flips in the top bits as tileLayersLoad reads them,
// 0 for an empty cell; unsigned, as Tiled writes it
function editorTileToGid(tile, direction=0, mirror=false)
{
if (tile === undefined) return 0;
const flips = tileLayersTiledFlips.findIndex(([d, m])=> d === direction && !!m === !!mirror);
return ((flips << 29) | (tile + 1)) >>> 0;
}
// a gid back to a tile, its quarter turns and mirror, undefined for an empty cell
function editorGidToTile(gid)
{
if (!gid) return;
const [direction, mirror] = tileLayersTiledFlips[gid >>> 29];
return {tile: (gid & 0x0fffffff) - 1, direction, mirror: !!mirror};
}
///////////////////////////////////////////////////////////////////////////////
// maps
// every map the game loaded, and one for each layer it made in code; the editor changes a map's data in place,
// so a game that loads the same map object again, as a restart does, gets the changes
const editorMapList = [];
// the tile layers of a map's Tiled layers in the order tileLayersLoad makes them, groups flattened
function editorTileLayers(layers, list=[])
{
for (const layer of layers)
{
if (layer.type === 'group')
editorTileLayers(layer.layers || [], list);
else if (!layer.type || layer.type === 'tilelayer')
list.push(layer);
}
return list;
}
// the tile layers' data arrays of a map, in the same order
const editorTileLayerData = (layers)=> editorTileLayers(layers).map((layer)=> layer.data);
// called by tileLayersLoad before it reads a map; a map it has not seen gets a record, and its autosave is copied
// in when the file is the one it was made over; a file that changed since keeps the autosave pending for the
// panel to apply or drop, and one that already has the autosaved data, saved from the editor, drops it
function editorMapRestore(map)
{
if (editorMapList.some((record)=> record.map === map)) return map; // loaded again, it has the changes
// a map object seen before, its record retired when its layers went, gets it back as it was, its data has the
// changes already
const retired = editorRetiredMaps.get(map);
if (retired)
{
editorRetire((record)=> record.key === retired.key);
editorMapList.push(retired);
return map;
}
// a map it can not read as plain gids, an infinite one or one with typed arrays, is left to tileLayersLoad,
// which asserts on what it can not load, and a layer made from it gets a map of its own when edited
const data = map.layers ? editorTileLayerData(map.layers) : [];
if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
const fileName = url?.split(/[?#]/)[0].split('/').pop() || 'level.json';
const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
originalSize: {width: map.width, height: map.height}, layers: []};
// a new copy of a map takes over from the one before, and maps whose layers are all gone step aside
editorRetire((other)=> other.key === record.key || editorObjectsGone(other) ||
other.layers.length && other.layers.every((layer)=> layer.live.destroyed));
editorMapList.push(record);
const saved = editorSaves()[record.key];
if (!saved) return map;
// an autosave from before the map's shape was in the hash matches on its tiles and objects, and one from before
// objects were matches on its tiles; one of a resized map is resized to first
const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width &&
(saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data));
if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
editorDiscardPending(record);
else if (!sameFile || !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) ||
!editorCopyData(data, saved.layers))
{
record.pending = saved;
console.warn(`LittleJS editor: ${record.fileName} changed since its autosaved edits, ` +
'open the editor (Esc then 0) to apply or drop them');
}
else
{
editorRestoreObjects(map, saved);
console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
'Save in the editor (Esc then 0) writes them to the file');
}
return map;
}
// the records of maps the game moved on from, by map object, for a map it loads again
const editorRetiredMaps = new WeakMap;
// take records out of the list, with the undo that would change them, so an undo never writes a map
// the game no longer shows; a map object loaded again gets its record back
function editorRetire(isRetired)
{
const gone = editorMapList.filter(isRetired);
if (!gone.length) return;
for (const record of gone)
{
editorMapList.splice(editorMapList.indexOf(record), 1);
record.synthetic || editorRetiredMaps.set(record.map, record);
}
for (const list of [editorUndoList, editorRedoList])
for (let i = list.length; i--;)
list[i].some((entry)=> gone.includes(editorEntryRecord(entry))) && list.splice(i, 1);
if (gone.includes(editorLayer?.record))
editorLayer = editorSelection = editorSelectionDrag = undefined; // the selection was an area of that layer
if (gone.includes(editorObjectLayer?.record))
editorObjectLayer = undefined;
}
// called by tileLayersLoad with the layers it made, a map loaded again keeps its layer records, with the new layers
function editorMapLoaded(map, tileLayers, flatLayers)
{
const record = editorMapList.find((record)=> record.map === map);
record && tileLayers.forEach((live, i)=>
{
const {dataLayer: source, color} = flatLayers[i];
const layer = record.layers.find((layer)=> layer.source === source);
layer ? Object.assign(layer, {live, color}) : record.layers.push({record, source, live, color});
});
}
// the editor's record of a live layer; a layer the game made in code gets a map of its own, from its tiles now
function editorLayerRecord(live)
{
for (const record of editorMapList)
for (const layer of record.layers)
if (layer.live === live) return layer;
const {x: width, y: height} = live.size, size = live.tileInfo?.size ?? vec2(16);
const data = [];
for (let y = height; y--;)
for (let x = 0; x < width; ++x)
{
const d = live.getData(vec2(x, y));
data.push(editorTileToGid(d.tile, d.direction, d.mirror));
}
const source = {type: 'tilelayer', id: 1, name: 'Tile Layer 1', x: 0, y: 0, width, height,
opacity: 1, visible: true, data};
const map = {type: 'map', version: '1.10', orientation: 'orthogonal', renderorder: 'right-down', infinite: false,
width, height, tilewidth: size.x, tileheight: size.y, nextlayerid: 2, nextobjectid: 1,
tilesets: editorTilesets(live), layers: [source]};
const record = {map, synthetic: true, fileName: 'level.json', layers: []};
const layer = {record, source, live, color: WHITE};
record.layers.push(layer);
editorMapList.push(record);
return layer;
}
// a Tiled tileset for a layer made in code, from its image
function editorTilesets(live)
{
const tileInfo = live.tileInfo, image = tileInfo?.textureInfo?.image;
if (!image) return [];
const {x: tilewidth, y: tileheight} = tileInfo.size, padding = tileInfo.padding;
const columns = tileInfo.columns || floor(image.width / (tilewidth + padding*2));
const rows = floor(image.height / (tileheight + padding*2));
const file = image.src ? image.src.split('/').pop() : 'tiles.png';
return [{firstgid: 1, name: file.replace(/\.\w+$/, ''), image: file, imagewidth: image.width,
imageheight: image.height, tilewidth, tileheight, margin: padding, spacing: padding*2, columns,
tilecount: columns * rows}];
}
// the file each json fetchJSON loaded came from, its name for saving and autosaving
const editorFetchedURLs = new WeakMap;
function editorJSONFetched(url, json)
{ json && typeof json === 'object' && editorFetchedURLs.set(json, String(url)); }
// a map as Tiled JSON, everything it was loaded with kept, the tile data as the editor left it
function editorMapJSON(record) { return JSON.stringify(record.map); }
// the files picked to save maps to, kept across reloads in IndexedDB by page and map, which is where a browser
// lets a page keep them; each resolves to undefined when the store can not be used
let editorFileStore =
{
get: (key)=> editorFileStoreRequest('readonly', (store)=> store.get(key)),
set: (key, handle)=> editorFileStoreRequest('readwrite', (store)=> handle ? store.put(handle, key) : store.delete(key)),
};
// a request on the editor's file store, resolves to its result, or undefined if anything failed
function editorFileStoreRequest(mode, request)
{
return new Promise((resolve)=>
{
try
{
const open = indexedDB.open('LittleJS editor', 1);
open.onupgradeneeded = ()=> open.result.createObjectStore('files');
open.onerror = ()=> resolve(undefined);
open.onsuccess = ()=>
{
const db = open.result, done = (result)=> { db.close(); resolve(result); };
try
{
const r = request(db.transaction('files', mode).objectStore('files'));
r.onsuccess = ()=> done(r.result);
r.onerror = ()=> done(undefined);
}
catch { done(undefined); }
};
}
catch { resolve(undefined); }
});
}
// the file a map was saved to on an earlier page load, when the browser gives permission to write it again
async function editorRememberedFile(record)
{
const handle = await editorFileStore.get(editorFileKey(record)), mode = {mode: 'readwrite'};
try
{
if (handle && (await handle.queryPermission(mode) === 'granted' ||
await handle.requestPermission(mode) === 'granted'))
return handle;
}
catch { } // a handle that can not ask, the picker instead
}
const editorFileKey = (record)=> (globalThis.location?.pathname ?? '') + ' ' + record.key;
// a map as a file Save wrote is the file from then on: Reset to file goes back to it, and a reload of it has nothing
// to apply; a download can not say it replaced the file, so it does not, and neither does a file of another name, a
// copy, since the game still loads the one it came from; the browser gives a picked file's name and not its folder,
// so a file of the same name may be a copy too, and the autosave stays, with the hash of the file the game loads and
// of the file written, until a reload shows which one the game loads
function editorSetBaseline(record, written)
{
if (record.synthetic) return; // a layer made in code has no file to load it from
const data = editorTileLayerData(written.layers);
const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []);
Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects,
editorMapLayout(written)), originalSize: {width: written.width, height: written.height}});
record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave
}
// save a map as Tiled JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
// written again on each Save after, even after a reload once the browser gives permission, or picked again with
// Save As; elsewhere as a download under the name of the file it came from; resolves to how it saved, undefined
// when the picker was closed
async function editorSave(record, pickAgain=false)
{
if (!record) return;
editorStrokeEnd();
const text = editorMapJSON(record), picker = /** @type {any} */ (globalThis).showSaveFilePicker;
if (picker)
{
try
{
if (!pickAgain && !record.fileHandle)
record.fileHandle = await editorRememberedFile(record);
if (pickAgain || !record.fileHandle)
{
record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
types: [{description: 'Tiled JSON', accept: {'application/json': ['.json']}}]});
editorFileStore.set(editorFileKey(record), record.fileHandle);
}
const writable = await record.fileHandle.createWritable();
await writable.write(text);
await writable.close();
// the browser gives the picked file's name but not its folder, so the name is what says it is the map's
if (record.fileHandle.name === record.fileName)
editorSetBaseline(record, JSON.parse(text));
return 'written';
}
catch (error)
{
if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
editorFileStore.set(editorFileKey(record), undefined);
}
}
saveText(text, record.fileName, 'application/json');
return 'downloaded';
}
///////////////////////////////////////////////////////////////////////////////
// autosave
// every page keeps its own autosaves, one for each map by its key
function editorSaveName() { return 'LittleJS editor ' + (globalThis.location?.pathname ?? ''); }
const editorSaves = ()=> readSaveData(editorSaveName(), {});
// if the last autosave did not fit in storage, the panel says so
let editorSaveFailed = false;
// write the autosaves, noting whether storage took them
function editorWriteSaves(saves)
{
try
{
localStorage.setItem(editorSaveName(), JSON.stringify(saves));
editorSaveFailed = false;
}
catch { editorSaveFailed = true; }
}
// a map's name for its autosave, the file it was fetched from without a query, or its size, layer names and data as loaded; a map with
// no file can not tell a changed map from another one, so each is its own
function editorMapKey(map, url, hash)
{ return url?.split(/[?#]/)[0] ?? `${map.width}x${map.height} ` + map.layers.map((layer)=> layer.name).join() + ' #' + hash; }
// a quick hash of a map's tile data, objects and shape, to know when the file changed under its autosave
function editorMapHash(data, objects=[], layout='')
{
let hash = 2166136261;
for (const layer of data)
for (const gid of layer)
hash = Math.imul(hash ^ gid, 16777619);
const text = (objects.length ? JSON.stringify(objects) : '') + layout;
for (let i = 0; i < text.length; ++i)
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
return hash >>> 0;
}
// a map's shape: its size, tile size, and its tile and object layers by name, in order, so a file whose tiles read
// the same laid out another way is a changed file
function editorMapLayout(map)
{
const names = (layers)=> layers.map((layer)=> layer.id + ':' + layer.name).join();
return `${map.width}x${map.height} ${map.tilewidth}x${map.tileheight} ` +
names(editorTileLayers(map.layers)) + ' | ' + names(editorObjectGroups(map.layers));
}
const editorSameData = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
// copy saved tile data into a map's layers in place, when every layer is the size it was
function editorCopyData(data, saved)
{
if (saved.length !== data.length || saved.some((layer, i)=> layer.length !== data[i].length)) return false;
saved.forEach((layer, i)=> { for (let j = layer.length; j--;) data[i][j] = layer[j]; });
return true;
}
// remember a map's tile data and objects, or forget them when they are back to the file
function editorAutosave(record)
{
if (record.synthetic) return; // a layer made in code has no load to bring it back in, save it to a file
const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers);
const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
const original = record.originalObjects ?? [], kept = objects.slice();
while (kept.length > original.length && !kept.at(-1).length)
kept.pop(); // an Objects layer the editor made, empty again, is not an edit
const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
// the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it
if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash)
delete saves[record.key];
else
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
layers: data, objects, nextobjectid: map.nextobjectid};
editorWriteSaves(saves);
}
// paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its
// object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height)
{
editorStrokeEnd();
if (width !== record.map.width || height !== record.map.height)
{
if (!levelEditor.onRestart) return;
const before = editorMapSnapshot(record);
editorSetMapSnapshot(record, {width, height, layers: data, objects});
editorMapChanged(record, before);
return;
}
const all = editorTileLayerData(record.map.layers);
editorBulkEdit(()=>
{
for (const layer of record.layers)
{
const gids = data[all.indexOf(layer.source.data)], {x: width, y: height} = layer.live.size;
if (gids?.length === width * height)
gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
}
editorObjectLayers(record).forEach((layer, i)=> objects &&
editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
});
editorStroke ? editorStrokeEnd() : editorAutosave(record);
}
// the autosaved edits of a file that changed since, applied as one undo; edits that do not fit the file now are
// kept waiting, with their autosave, until they are dropped, and it returns false
function editorApplyPending(record)
{
const saved = record?.pending;
if (!saved) return false;
if (!editorPendingFits(record, saved))
{
record.pendingUnfit = true;
console.warn(`LittleJS editor: the autosaved edits to ${record.fileName} do not fit the file now, ` +
'they are kept until dropped');
return false;
}
record.pending = record.pendingUnfit = undefined;
editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
return true;
}
// if autosaved edits fit a map now: as many tile layers, each the size they were saved at, which needs the Restart
// hook when the map is another size, and no objects in object layers the map no longer has
function editorPendingFits(record, saved)
{
const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
const width = saved.width ?? map.width, height = saved.height ?? map.height;
return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
(saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
}
// drop the autosaved edits of a file that changed since
function editorDiscardPending(record)
{
if (!record) return;
record.pending = record.pendingUnfit = undefined;
const saves = editorSaves();
delete saves[record.key];
editorWriteSaves(saves);
}
// put every layer back to the file, its size too, as one undo
function editorRevert(record)
{
if (!record || record.synthetic) return; // a layer made in code has no file
record.pending = record.pendingUnfit = undefined;
const {width, height} = record.originalSize ?? record.map;
editorPaintData(record, record.original, record.originalObjects, width, height);
}
///////////////////////////////////////////////////////////////////////////////
// resizing
// change a map's size in place, at the right and top, so the tiles and objects keep their places from the bottom
// left; a row added goes at the start of each layer's data, Tiled's rows run from the top, and objects past the new
// edges are dropped; returns false for a size it can not have
function editorResizeMap(map, width, height)
{
if (!(width >= 1 && height >= 1 && width % 1 === 0 && height % 1 === 0)) return false;
if (width === map.width && height === map.height) return true;
const oldWidth = map.width, added = height - map.height;
for (const layer of editorTileLayers(map.layers))
{
const old = [...layer.data];
layer.data.length = 0;
for (let row = 0; row < height; ++row)
for (let x = 0; x < width; ++x)
{
const oldRow = row - added;
layer.data.push(x < oldWidth && oldRow >= 0 && old[x + oldRow * oldWidth] || 0);
}
layer.width = width;
layer.height = height;
}
// an object on the map before and off it after is dropped, one that was already off it stays
const {tilewidth=1, tileheight=1} = map, oldHeight = map.height;
const inside = (object, w, h)=> object.x >= 0 && object.x < w * tilewidth && object.y > 0 && object.y <= h * tileheight;
for (const group of editorObjectGroups(map.layers))
{
group.objects &&= group.objects.filter((object)=>
{
const was = inside(object, oldWidth, oldHeight);
object.y += added * tileheight;
return !was || inside(object, width, height);
});
}
map.width = width;
map.height = height;
return true;
}
// a map's size, tiles and objects, for an undo
function editorMapSnapshot(record)
{
const map = record.map, objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects});
}
// give a map a size, tiles and objects, the game's Restart hook makes its layers again
function editorSetMapSnapshot(record, snapshot)
{
const map = record.map;
editorResizeMap(map, snapshot.width, snapshot.height);
editorCopyData(editorTileLayerData(map.layers), snapshot.layers);
editorRestoreObjects(map, snapshot);
for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length))
group.objects = []; // an Objects layer the editor made since
editorClearSelections();
}
// the selections go when the map changes size, they are cells and objects of the old one
function editorClearSelections()
{
editorSelection = editorSelectionDrag = editorLastCell = editorHover = undefined;
editorObjectSelection.clear();
}
// a map changed whole, the undo can put it back; it is autosaved, and the game makes its level again, in the editor
function editorMapChanged(record, before)
{
const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
editorUndoList.push(stroke);
editorRedoList.length = 0;
editorChanged(stroke);
}
// resize a map, as one undo; a game without the Restart hook can not make its layers again, so it can not
function editorResize(record, width, height)
{
const map = record?.map;
if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
if (width === map.width && height === map.height) return false;
editorStrokeEnd();
const before = editorMapSnapshot(record);
if (!editorResizeMap(map, width, height)) return false;
editorClearSelections();
editorMapChanged(record, before);
return true;
}
///////////////////////////////////////////////////////////////////////////////
// cells and undo
// the strokes that can be undone and redone, each a list of cells with the gid before and after, and of object
// layers with their objects before and after, or a resize with the map before and after
const editorUndoList = [], editorRedoList = [];
const editorEntryRecord = (entry)=> entry.resize ?? (entry.layer ?? entry.objectLayer).record;
let editorStroke;
// during a big edit, the layers whose cell by cell redraws are held off, with the redraw each had of its own
let editorHeldRedraws;
// make a big edit, a fill, clear, undo, apply or revert, with each layer's cell by cell redraws held off, a game's
// tile callback's included; the stroke's end draws each layer it touched again whole
function editorBulkEdit(edit)
{
const held = editorHeldRedraws = new Map;
try { edit(); }
finally
{
editorHeldRedraws = undefined;
for (const [live, own] of held)
own ? live.redrawTileData = own : delete live.redrawTileData;
}
}
// set a cell to a gid, in the map and on the live layer, with the game's rules, and return the gid it had
function editorSetCell(layer, pos, gid)
{
const {source, live, color} = layer;
const index = pos.x + (live.size.y - 1 - pos.y) * live.size.x;
const before = source.data[index];
if (before === gid && (!gid || live.destroyed || live.getData(pos).tile !== undefined))
return before; // the same, unless the layer lost the tile in play, a ghost painted with its own tile
source.data[index] = gid;
if (live.destroyed) return before; // a layer the game let go of, the map still has the change
if (editorHeldRedraws && !editorHeldRedraws.has(live))
{
editorHeldRedraws.set(live, live.hasOwnProperty('redrawTileData') ? live.redrawTileData : undefined);
live.redrawTileData = ()=> {};
}
const t = editorGidToTile(gid);
live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
if (levelEditor.onTile)
levelEditor.onTile(live, pos.copy(), t?.tile);
else if (live instanceof TileCollisionLayer && live.isSolid)
live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
return before;
}
// paint a cell as part of the stroke being made
function editorPaint(layer, pos, gid)
{
const before = editorSetCell(layer, pos, gid);
if (before === gid) return;
(editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
}
// the stroke is done, it can be undone as one
function editorStrokeEnd()
{
if (!editorStroke) return;
editorUndoList.push(editorStroke);
editorRedoList.length = 0;
editorChanged(editorStroke);
editorStroke = undefined;
}
// undo the stroke being made, with no undo entry, as if it was never made
function editorStrokeCancel()
{
const stroke = editorStroke;
editorStroke = undefined;
if (!stroke) return;
editorBulkEdit(()=>
{
for (const entry of [...stroke].reverse())
entry.objectLayer ? editorSetObjects(entry.objectLayer, entry.before) :
editorSetCell(entry.layer, entry.pos, entry.before);
});
editorRedraw(stroke);
}
// undo the last stroke, or redo the last one undone
function editorUndo(redo=false)
{
editorStrokeEnd();
const stroke = (redo ? editorRedoList : editorUndoList).pop();
if (!stroke) return;
(redo ? editorUndoList : editorRedoList).push(stroke);
editorBulkEdit(()=>
{
for (const entry of redo ? stroke : [...stroke].reverse())
{
if (entry.resize)
editorSetMapSnapshot(entry.resize, redo ? entry.after : entry.before);
else if (entry.objectLayer)
editorSetObjects(entry.objectLayer, redo ? entry.after : entry.before);
else
editorSetCell(entry.layer, entry.pos, redo ? entry.after : entry.before);
}
});
editorChanged(stroke);
}
// after a change the tile layers it touched draw again whole, so a game's onRedraw decoration sees the new tiles,
// and the maps it touched are autosaved; a resized map is made again by the game, with the editor staying open
function editorChanged(stroke)
{
editorRedraw(stroke);
for (const record of new Set(stroke.map(editorEntryRecord)))
editorAutosave(record);
stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
}
// the tile layers a stroke touched draw again whole
function editorRedraw(stroke)
{
for (const layer of new Set(stroke.filter((entry)=> entry.layer).map((entry)=> entry.layer)))
layer.live.destroyed || layer.live.redraw();
}
///////////////////////////////////////////////////////////////////////////////
// objects
// the object layers of a map's Tiled layers in order, groups flattened
function editorObjectGroups(layers, list=[])
{
for (const layer of layers || [])
{
if (layer.type === 'group')
editorObjectGroups(layer.layers, list);
else if (layer.type === 'objectgroup')
list.push(layer);
}
return list;
}
// the editor's object layers of a map, each {record, isObjects, group, name, instances}: group is the Tiled object
// layer, undefined for one not made yet, and instances what the game made for each object id; a map with no object
// layer gets one to place objects in once the game has added a type, made in the map the first time it is used
function editorObjectLayers(record)
{
if (!record || record.synthetic) return [];
record.objectLayers ||= editorObjectGroups(record.map.layers).map((group)=>
({record, isObjects: true, group, name: group.name || 'Objects', instances: new Map}));
if (!record.objectLayers.length && objectLayersTypes.size)
record.objectLayers.push({record, isObjects: true, group: undefined, name: 'Objects', instances: new Map});
return record.objectLayers;
}
// a new Tiled object layer named Objects, on top of a map
function editorNewObjectGroup(map)
{
const id = map.nextlayerid ?? map.layers.length + 1, group = {draworder: 'topdown', id, name: 'Objects',
objects: [], opacity: 1, type: 'objectgroup', visible: true, x: 0, y: 0};
map.nextlayerid = id + 1;
map.layers.push(group);
return group;
}
// the Tiled object layer of an editor object layer, made the first time it is needed
function editorObjectGroup(layer) { return layer.group ||= editorNewObjectGroup(layer.record.map); }
// the next object id of a map, as Tiled counts them, and past every id the map has, for a map with no nextobjectid
// or one an autosave left behind
function editorNextObjectId(map)
{
const ids = editorObjectGroups(map.layers).flatMap((group)=> (group.objects ?? []).map((object)=> object.id));
const id = max(map.nextobjectid ?? 1, ...ids.map((id)=> id + 1));
map.nextobjectid = id + 1;
return id;
}
// an object's world position, as objectLayersLoad places it
function editorObjectPos(record, object)
{
const {height=0, tilewidth=1, tileheight=1} = record.map;
return vec2(object.x / tilewidth, height - object.y / tileheight);
}
// set an object's position in the map from a world position
function editorObjectSetPos(record, object, pos)
{
const {height=0, tilewidth=1, tileheight=1} = record.map;
object.x = pos.x * tilewidth;
object.y = (height - pos.y) * tileheight;
}
// called by objectLayersLoad for each object it read, the editor keeps what the game made by the object's id
function editorObjectMade(map, group, object, made)
{
const record = editorMapList.find((record)=> record.map === map);
const layer = editorObjectLayers(record).find((layer)=> layer.group === group);
layer && (made ? layer.instances.set(object.id, made) : layer.instances.delete(object.id));
}
// a copy of objects as undo and autosave keep them, and an object layer's objects now
const editorObjectsCopy = (value)=> JSON.parse(JSON.stringify(value));
const editorObjectList = (layer)=> editorObjectsCopy(layer.group?.objects ?? []);
// set an object layer's objects to a list and bring the game's objects in line: one gone is destroyed, a new one
// made, a moved one moved, one with changed properties given them; a type made by an arrow function, like a player
// start, is called again when its object changes, and a class is not made again for an object the game let go of
function editorSetObjects(layer, list)
{
const {record, instances} = layer, group = editorObjectGroup(layer);
const before = new Map(group.objects.map((object)=> [object.id, object]));
group.objects = editorObjectsCopy(list);
const ids = new Set(group.objects.map((object)=> object.id));
for (const [id, made] of instances)
{
if (ids.has(id)) continue;
made.destroy?.();
instances.delete(id);
}
for (const object of group.objects)
{
const old = before.get(object.id), made = instances.get(object.id);
const name = object.type || object.class, type = objectLayersTypes.get(name);
if (!type || old && editorSameData(old, object)) continue;
if (!old || (old.type || old.class) !== name || !type.make.prototype)
{
// new, of a new type, or a function to call again
made?.destroy?.();
const result = objectLayersMake(record.map, object);
result ? instances.set(object.id, result) : instances.delete(object.id);
}
else if (made && !made.destroyed)
{
// only what the edit changed, so moving an object keeps its health and the rest of its state from play
if ('pos' in made && (old.x !== object.x || old.y !== object.y))
made.pos = editorObjectPos(record, object);
const was = objectLayersProperties(type, old), now = objectLayersProperties(type, object);
for (const name in now)
editorSameData(was[name], now[name]) || (made[name] = now[name]);
}
}
}
// change an object layer's objects as part of the stroke being made: change edits a copy of the list; one stroke's
// changes to one layer are one undo entry, so a drag is one undo
function editorChangeObjects(layer, change)
{
if (!layer || layer.record.pending) return false; // its autosaved edits wait to be applied or dropped first
const before = editorObjectList(layer), after = editorObjectsCopy(before);
change(after);
if (editorSameData(before, after)) return false;
editorSetObjects(layer, after);
const last = editorStroke?.at(-1);
if (last?.objectLayer === layer)
last.after = after;
else
(editorStroke ||= []).push({objectLayer: layer, before, after});
return true;
}
// set an object's property as Tiled keeps it, only where it differs from its type's default, a color as #AARRGGBB
// and a Vector2 as the string x,y
function editorObjectSetProperty(object, name, value, defaultValue)
{
if (Number.isInteger(defaultValue) && isNumber(value))
value = round(value); // an integer stays one, as Tiled keeps an int
const properties = (object.properties ?? []).filter((property)=> property.name !== name);
const text = (v)=> isColor(v) ? v.toString() : isVector2(v) ? v.x + ',' + v.y : JSON.stringify(v);
if (text(value) !== text(defaultValue))
{
const hex = isColor(value) && value.toString();
properties.push(hex ? {name, type: 'color', value: '#' + hex.slice(7, 9) + hex.slice(1, 7)} :
isVector2(value) ? {name, type: 'string', value: text(value)} :
{name, type: typeof value === 'boolean' ? 'bool' : typeof value === 'string' ? 'string' :
Number.isInteger(defaultValue) ? 'int' : 'float', value});
}
if (properties.length)
object.properties = properties;
else
delete object.properties;
}
// copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks
function editorRestoreObjects(map, saved)
{
const groups = editorObjectGroups(map.layers);
(saved.objects ?? []).forEach((objects, i)=>
{
(groups[i] ?? editorNewObjectGroup(map)).objects = editorObjectsCopy(objects);
});
if (saved.nextobjectid > (map.nextobjectid ?? 1))
map.nextobjectid = saved.nextobjectid;
}
///////////////////////////////////////////////////////////////////////////////
// object editing
// the object layer being edited, undefined while a tile layer is; the ids of the objects selected in it; the object
// brush and the last objects copied, each a list of {type, properties, offset} placed at the mouse plus the offset;
// a left drag moving the selected objects, and a right drag's box
let editorObjectLayer, editorObjectSelection = new Set, editorObjectBrush, editorObjectClipboard;
let editorObjectDrag, editorObjectBox;
// make a layer the one being edited, a tile layer or an object layer; an object layer keeps a tile layer of its map
// as editorLayer, for All Layers
function editorSelectLayer(layer)
{
editorStrokeEnd();
editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
editorObjectSelection.clear();
if (layer.isObjects)
{
editorObjectLayer = layer;
if (editorLayer?.record !== layer.record)
editorLayer = layer.record.layers[0]; // none for a level of objects alone
}
else
{
editorObjectLayer = undefined;
editorLayer = layer;
}
}
// the size of an object for picking it, what the game made it, or a cell
function editorObjectSize(layer, object)
{
const made = layer.instances.get(object.id);
return made && !made.destroyed && isVector2(made.size) ? made.size : vec2(1);
}
// the object under a world position, the one drawn last first
function editorObjectAt(layer, pos)
{
const objects = layer.group?.objects ?? [];
for (let i = objects.length; i--;)
{
const object = objects[i], center = editorObjectPos(layer.record, object);
const size = editorObjectSize(layer, object);
if (abs(pos.x - center.x) <= size.x / 2 && abs(pos.y - center.y) <= size.y / 2)
return object;
}
}
// the ids of the objects whose positions are inside the box between two world positions
function editorObjectsIn(layer, a, b)
{
const low = vec2(min(a.x, b.x), min(a.y, b.y)), high = vec2(max(a.x, b.x), max(a.y, b.y));
return (layer.group?.objects ?? []).filter((object)=>
{
const pos = editorObjectPos(layer.record, object);
return pos.x >= low.x && pos.y >= low.y && pos.x <= high.x && pos.y <= high.y;
}).map((object)=> object.id);
}
// the brush takes an object's type and properties
function editorPickObject(object)
{ editorObjectBrush = [{type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []), offset: vec2()}]; }
// a new Tiled point object for a brush's object, at a world position, with the map's next id
function editorNewObject(record, {type, properties}, pos)
{
const object = {id: editorNextObjectId(record.map), name: '', type, point: true, rotation: 0, visible: true,
width: 0, height: 0, x: 0, y: 0};
properties.length && (object.properties = editorObjectsCopy(properties));
editorObjectSetPos(record, object, pos);
return object;
}
// place an object brush's objects at a position plus each one's offset, as one undo
function editorPlaceObjects(layer, pos, brush)
{
editorChangeObjects(layer, (list)=>
{
for (const object of brush)
list.push(editorNewObject(layer.record, object, pos.add(object.offset)));
});
editorStrokeEnd();
}
// the selected objects of the object layer being edited
function editorSelectedObjects()
{ return (editorObjectLayer?.group?.objects ?? []).filter((object)=> editorObjectSelection.has(object.id)); }
// remove the selected objects, as one undo
function editorDeleteObjects()
{
if (!editorObjectSelection.size) return false;
const selection = editorObjectSelection;
editorStrokeEnd();
editorChangeObjects(editorObjectLayer, (list)=>
list.splice(0, list.length, ...list.filter((object)=> !selection.has(object.id))));
editorObjectSelection = new Set;
editorStrokeEnd();
return true;
}
// the selected objects into the brush, their offsets from the lowest corner of their positions, and the selection
// cleared so the next click places them
function editorCopyObjects()
{
const objects = editorSelectedObjects();
if (!objects.length) return false;
const positions = objects.map((object)=> editorObjectPos(editorObjectLayer.record, object));
const low = vec2(min(...positions.map((p)=> p.x)), min(...positions.map((p)=> p.y)));
editorObjectBrush = editorObjectClipboard = objects.map((object, i)=> ({type: object.type || object.class,
properties: editorObjectsCopy(object.properties ?? []), offset: positions[i].subtract(low)}));
editorObjectSelection.clear();
return true;
}
// copy the selected objects, then remove them, as one undo
function editorCutObjects()
{
const selection = new Set(editorObjectSelection);
if (!editorCopyObjects()) return false;
editorObjectSelection = selection;
return editorDeleteObjects();
}
// the last copied objects back into the brush
function editorPasteObjects()
{
if (!editorObjectClipboard) return false;
editorObjectBrush = editorObjectClipboard;
return true;
}
// the mouse on an object layer: the left places, selects and drags objects, the right picks one or box-selects,
// Shift adding and Ctrl taking away
function editorUpdateObjects(space)
{
const layer = editorObjectLayer, record = layer.record, mouse = screenToWorld(editorMouseScreen);
const snap = editorSnap;
const shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
const ctrl = keyIsDown('ControlLeft') || keyIsDown('ControlRight') || keyIsDown('MetaLeft') || keyIsDown('MetaRight');
editorHover = undefined;
// the right button: a click picks the object under the mouse, or clears the selection, a drag box-selects
if (mouseWasPressed(2))
editorRightPress = {screen: mousePosScreen.copy(), world: mouse.copy(), drag: false};
const press = editorRightPress;
if (press)
{
press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2);
editorObjectBox = press.drag ? {a: press.world, b: mouse} : undefined;
if (!mouseIsDown(2))
{
const hit = !press.drag && editorObjectAt(layer, mouse);
const ids = press.drag ? editorObjectsIn(layer, press.world, mouse) : hit ? [hit.id] : [];
if (press.drag || hit && (shift || ctrl))
{
shift || ctrl || editorObjectSelection.clear();
for (const id of ids)
ctrl ? editorObjectSelection.delete(id) : editorObjectSelection.add(id);
}
else if (hit)
editorPickObject(hit);
else
editorObjectSelection.clear();
editorRightPress = editorObjectBox = undefined;
}
return;
}
// the left button: on an object selects it and starts a drag of the selection, on empty space the first click
// with a selection only clears it, otherwise it places the brush
if (mouseWasPressed(0) && !space)
{
const hit = editorObjectAt(layer, mouse);
if (hit)
{
if (!editorObjectSelection.has(hit.id))
editorObjectSelection = new Set([hit.id]);
editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
[object.id, editorObjectPos(record, object)]))};
}
else if (editorObjectSelection.size)
editorObjectSelection.clear();
else if (editorObjectBrush)
editorPlaceObjects(layer, snap(mouse), editorObjectBrush);
}
const drag = editorObjectDrag;
if (drag && mouseIsDown(0))
{
const delta = snap(mouse).subtract(drag.start);
editorChangeObjects(layer, (list)=>
{
for (const object of list)
{
const from = drag.from.get(object.id);
from && editorObjectSetPos(record, object, from.add(delta));
}
});
}
if (!mouseIsDown(0))
{
editorObjectDrag = undefined;
editorStrokeEnd();
}
}
///////////////////////////////////////////////////////////////////////////////
// editing
// every tile layer in the game, as the editor's records, in render order, then the object layers of their maps, and
// of the maps the game loaded objects alone from
function editorLayers()
{
const tiles = engineObjects.filter((o)=> o instanceof TileLayer && !o.destroyed)
.sort((a, b)=> a.renderOrder - b.renderOrder).map(editorLayerRecord);
const records = [...new Set(tiles.map((layer)=> layer.record))];
for (const record of editorMapList)
record.synthetic || record.layers.length || records.includes(record) || records.push(record);
return [...tiles, ...records.flatMap(editorObjectLayers)];
}
// if a map of objects alone has nothing left in the game, the level moved on, a marker that is no game object counts
// as nothing
const editorObjectsGone = (record)=> !record.layers.length &&
editorObjectLayers(record).every((layer)=> [...layer.instances.values()].every((made)=> !made.destroy || made.destroyed));
// the cell of a layer at a world position, undefined off the layer
function editorCellAt(live, worldPos)
{
const pos = worldPos.subtract(live.pos).floor();
return pos.arrayCheck(live.size) ? pos : undefined;
}
// the cell of a layer nearest a world position, clamped onto the layer
function editorCellClamped(live, worldPos)
{
const pos = worldPos.subtract(live.pos).floor();
return vec2(clamp(pos.x, 0, live.size.x - 1), clamp(pos.y, 0, live.size.y - 1));
}
// the area between two cells, corners in any order
function editorArea(a, b)
{ return {min: vec2(min(a.x, b.x), min(a.y, b.y)), max: vec2(max(a.x, b.x), max(a.y, b.y))}; }
// the tile info a layer draws a tile with, as TileLayer.drawTileData picks it
function editorTileInfo(live, tile)
{
const t = live.tileInfo;
return t && (t.columns ? t.frame(tile) : t.index(tile));
}
///////////////////////////////////////////////////////////////////////////////
// stamps
// the brush is a stamp: its size and, for each layer it paints, a grid of gids from its bottom row up, undefined
// see-through; one grid paints the layer being edited, one copied from all layers has a grid for each layer of the
// map, in order; the last one copied is kept for Ctrl+V
let editorBrush = editorStampTile(editorTileToGid(0)), editorClipboard;
// a stamp of one tile, gid 0 is the Erase brush
function editorStampTile(gid) { return {width: 1, height: 1, grids: [[gid]]}; }
// the gid in a cell of a layer, as the map holds it
function editorGidAt(layer, cell)
{ return layer.source.data[cell.x + (layer.live.size.y - 1 - cell.y) * layer.live.size.x]; }
// each layer a stamp paints with its grid: a stamp of one grid paints the layer being edited, one copied from all
// layers paints the layers of that layer's map, as many as it has
function editorStampTargets(layer, stamp)
{
return stamp.grids.length > 1 ?
layer.record.layers.slice(0, stamp.grids.length).map((target, i)=> [target, stamp.grids[i]]) :
[[layer, stamp.grids[0]]];
}
// the grid of a stamp that paints a layer
function editorStampGrid(layer, stamp)
{ return stamp.grids.length > 1 ? stamp.grids[layer.record.layers.indexOf(layer)] ?? [] : stamp.grids[0]; }
// paint a stamp with its bottom left on a cell, its see-through cells and cells off the layer leave things as they are
function editorPaintStamp(layer, pos, stamp=editorBrush)
{
if (layer.record.pending) return; // its autosaved edits wait to be applied or dropped first
for (const [target, grid] of editorStampTargets(layer, stamp))
for (let y = stamp.height; y--;)
for (let x = stamp.width; x--;)
{
const gid = grid[x + y * stamp.width], cell = pos.add(vec2(x, y));
gid === undefined || !cell.arrayCheck(target.live.size) || editorPaint(target, cell, gid);
}
editorLastPlaced = {layer, pos: pos.copy()};
}
// paint a stamp on every cell of the line between two cells
function editorPaintBetween(layer, from, to, stamp=editorBrush)
{
const steps = max(abs(to.x - from.x), abs(to.y - from.y));
for (let i = 0; i <= steps; ++i)
editorPaintStamp(layer, from.lerp(to, steps ? i / steps : 1).floor(), stamp);
}
// paint a stamp along a drag from its last cell, so a fast drag leaves no gaps
function editorPaintLine(layer, pos, stamp=editorBrush)
{
editorPaintBetween(layer, editorLastCell ?? pos, pos, stamp);
editorLastCell = pos;
}
// a gid turned a quarter turn clockwise on screen: Tiled draws direction one that way, flipped diagonally then
// horizontally, and tileLayersLoad reads it the same, so a turned stamp looks the same in both
function editorGidTurn(gid)
{
const t = editorGidToTile(gid);
return t ? editorTileToGid(t.tile, (t.direction + 1) % 4, t.mirror) : gid;
}
// a gid mirrored left to right, Tiled's horizontal flip: a mirror over a turn is the mirror under the turn the
// other way
function editorGidMirror(gid)
{
const t = editorGidToTile(gid);
return t ? editorTileToGid(t.tile, (4 - t.direction) % 4, !t.mirror) : gid;
}
// a stamp turned a quarter turn clockwise on screen, or back, its cells, their tiles and its objects together; cell
// (x, y) goes to (y, width - 1 - x), so the bottom row becomes the left column, its left end at the top
function editorStampTurn(stamp, back=false)
{
for (let turns = back ? 3 : 1; turns--;)
{
const {width, height} = stamp;
const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(object.offset.y, width - object.offset.x)}));
stamp = {width: height, height: width, grids: stamp.grids.map((grid)=>
{
const turned = [];
for (let y = height; y--;)
for (let x = width; x--;)
turned[y + (width - 1 - x) * height] = editorGidTurn(grid[x + y * width]);
return turned;
}), ...(objects && {objects})};
}
return stamp;
}
// a stamp mirrored left to right, its cells, their tiles and its objects together
function editorStampMirror(stamp)
{
const {width, height} = stamp;
const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(width - object.offset.x, object.offset.y)}));
return {width, height, grids: stamp.grids.map((grid)=>
{
const mirrored = [];
for (let y = height; y--;)
for (let x = width; x--;)
mirrored[width - 1 - x + y * width] = editorGidMirror(grid[x + y * width]);
return mirrored;
}), ...(objects && {objects})};
}
// the brush's tile when it is one tile, with its turn and mirror, undefined for the Erase brush or a bigger stamp
function editorBrushTile()
{
const {width, height, grids} = editorBrush;
return width === 1 && height === 1 && grids.length === 1 ? editorGidToTile(grids[0][0]) : undefined;
}
// the brush takes the tile in a cell, an empty cell gives the Erase brush
function editorPick(layer, pos) { editorBrush = editorStampTile(editorGidAt(layer, pos) || 0); }
// a palette slot into the brush: slot 0 is the Erase brush, slot n the palette's nth tile, keeping a one tile
// brush's turn; on an object layer slot n is the nth object type
function editorPalettePick(slot)
{
if (editorObjectLayer)
{
// on an object layer the palette is the types the game added, in order
const type = [...objectLayersTypes.keys()][slot];
type && (editorObjectBrush = [{type, properties: [], offset: vec2()}]);
return;
}
const t = editorBrushTile();
const tile = slot && (editorPaletteTiles(editorLayer)[slot - 1]?.tile ?? slot - 1); // every tile, in order
editorBrush = editorStampTile(slot ? editorTileToGid(tile, t?.direction, t?.mirror) : 0);
}
///////////////////////////////////////////////////////////////////////////////
// selection
// the layers the selection acts on, the one being edited, or all of its map's with All Layers on
function editorSelectionLayers()
{ return !editorLayer ? [] : editorAllLayers ? editorLayer.record.layers : [editorLayer]; }
// call back for each selected cell that is on a layer
function editorSelectionCells(live, callback)
{
const {min: a, max: b} = editorSelection;
for (let y = a.y; y <= b.y; ++y)
for (let x = a.x; x <= b.x; ++x)
{
const cell = vec2(x, y);
cell.arrayCheck(live.size) && callback(cell);
}
}
// fill with the brush: the selection when there is one, or the cells joined to the one under the mouse that
// match it exactly, on the layer being edited; a stamp repeats across it, as one undo
function editorFill()
{
const layer = editorLayer, stamp = editorBrush, {width: w, height: h} = stamp;
if (!layer || layer.live.destroyed || layer.record.pending) return;
editorStrokeEnd(); // a drag held while F is pressed is an undo of its own
const paint = (target, grid, cell, anchor)=>
{
const x = ((cell.x - anchor.x) % w + w) % w, y = ((cell.y - anchor.y) % h + h) % h;
const gid = grid[x + y * w];
gid === undefined || editorPaint(target, cell, gid);
};
editorBulkEdit(()=>
{
if (editorSelection)
{
for (const [target, grid] of editorStampTargets(layer, stamp))
editorSelectionCells(target.live, (cell)=> paint(target, grid, cell, editorSelection.min));
}
else if (editorHover)
{
const grid = editorStampGrid(layer, stamp), start = editorHover;
for (const cell of editorFloodCells(layer, start))
paint(layer, grid, cell, start);
}
});
editorStrokeEnd();
}
// the cells joined through their sides to a cell, holding the same gid, turn and mirror included
function editorFloodCells(layer, start)
{
// cells by index, x + y * width, each looked at once
const {x: width, y: height} = layer.live.size, gid = editorGidAt(layer, start);
const seen = new Uint8Array(width * height), cells = [], open = [start.x + start.y * width];
while (open.length)
{
const i = open.pop();
if (seen[i]) continue;
seen[i] = 1;
const x = i % width, y = i / width | 0, cell = vec2(x, y);
if (editorGidAt(layer, cell) !== gid) continue;
cells.push(cell);
x + 1 < width && open.push(i + 1);
x && open.push(i - 1);
y + 1 < height && open.push(i + width);
y && open.push(i - width);
}
return cells;
}
// clear the selected area, with All Layers the objects in it too, as one undo
function editorClear()
{
if (!editorSelection) return;
editorStrokeEnd(); // a drag held while Delete is pressed is an undo of its own
editorBulkEdit(()=>
{
for (const layer of editorSelectionLayers())
layer.record.pending || editorSelectionCells(layer.live, (cell)=> editorPaint(layer, cell, 0));
if (editorAllLayers && editorLayer)
{
const corner = editorLayer.live.pos.add(editorSelection.min);
const far = editorLayer.live.pos.add(editorSelection.max).add(vec2(1));
for (const objectLayer of editorObjectLayers(editorLayer.record))
{
const ids = new Set(editorObjectsIn(objectLayer, corner, far));
ids.size && editorChangeObjects(objectLayer, (list)=>
list.splice(0, list.length, ...list.filter((object)=> !ids.has(object.id))));
}
}
});
editorStrokeEnd();
}
// the objects of a map's object layers inside a tile area, each {group, type, properties, offset}: the index of its
// object layer, and its offset from the area's bottom left corner, as a stamp keeps them
function editorAreaObjects(layer, area)
{
const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
return editorObjectLayers(layer.record).flatMap((objectLayer, group)=>
{
const ids = new Set(editorObjectsIn(objectLayer, corner, far));
return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=>
({group, type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []),
offset: editorObjectPos(layer.record, object).subtract(corner)}));
});
}
// place a stamp's objects with the stamp's bottom left on a cell of a tile layer, into its map's object layers
function editorPlaceStampObjects(layer, cell, stamp)
{
const targets = editorObjectLayers(layer.record), corner = layer.live.pos.add(cell);
targets.forEach((target, group)=>
{
const objects = (stamp.objects ?? []).filter((object)=> (targets[object.group] ? object.group : 0) === group);
objects.length && editorChangeObjects(target, (list)=>
{
for (const object of objects)
list.push(editorNewObject(layer.record, object, corner.add(object.offset)));
});
});
}
// the tiles of an area on the selection layers as a stamp, its empty cells see-through
function editorSelectionStamp(area)
{
const width = area.max.x - area.min.x + 1, height = area.max.y - area.min.y + 1;
const grids = editorSelectionLayers().map((layer)=>
{
const grid = [];
for (let y = height; y--;)
for (let x = width; x--;)
{
const cell = area.min.add(vec2(x, y));
grid[x + y * width] = cell.arrayCheck(layer.live.size) && editorGidAt(layer, cell) || undefined;
}
return grid;
});
return {width, height, grids};
}
// the selected area into the brush as a stamp, its empty cells see-through, with All Layers the objects in it too,
// and the selection cleared so the next click paints it
function editorCopy()
{
const area = editorSelection;
if (!area || !editorLayer) return false;
const stamp = editorSelectionStamp(area), objects = editorAllLayers ? editorAreaObjects(editorLayer, area) : [];
if (!objects.length && stamp.grids.every((grid)=> grid.every((gid)=> gid === undefined)))
return false; // nothing there, the brush stays as it was
editorBrush = editorClipboard = objects.length ? {...stamp, objects} : stamp;
editorSelection = undefined;
return true;
}
// start dragging the selection from a cell in it, with All Layers the objects in it go too
function editorSelectionDragStart(layer, cell)
{
editorStrokeEnd();
const area = editorSelection, objects = [];
if (editorAllLayers)
{
const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
for (const objectLayer of editorObjectLayers(layer.record))
{
const ids = new Set(editorObjectsIn(objectLayer, corner, far));
ids.size && objects.push({objectLayer, ids});
}
}
editorSelectionDrag = {layer, start: cell, delta: vec2(), area, stamp: editorSelectionStamp(area), objects};
}
// move the dragged selection to a cell, as the stroke being made: taken back, then its area cleared and its tiles
// and objects put down that far from where they were; letting go ends the stroke, one undo
function editorSelectionDragTo(cell)
{
const drag = editorSelectionDrag, delta = cell.subtract(drag.start), {layer, area, stamp} = drag;
if (delta.x === drag.delta.x && delta.y === drag.delta.y) return;
drag.delta = delta;
editorStrokeCancel();
editorSelection = {min: area.min.add(delta), max: area.max.add(delta)};
if (!delta.x && !delta.y) return; // back where it was, no change
editorBulkEdit(()=>
{
for (const [target] of editorStampTargets(layer, stamp))
for (let y = area.min.y; y <= area.max.y; ++y)
for (let x = area.min.x; x <= area.max.x; ++x)
{
const from = vec2(x, y);
from.arrayCheck(target.live.size) && editorPaint(target, from, 0);
}
editorPaintStamp(layer, editorSelection.min, stamp);
for (const {objectLayer, ids} of drag.objects)
editorChangeObjects(objectLayer, (list)=>
{
for (const object of list)
ids.has(object.id) && editorObjectSetPos(layer.record, object,
editorObjectPos(layer.record, object).add(delta));
});
});
editorStroke && editorRedraw(editorStroke);
}
// put the dragged selection back where it was, with nothing to undo
function editorSelectionDragCancel()
{
editorStrokeCancel();
editorSelection = editorSelectionDrag.area;
editorSelectionDrag = undefined;
}
// copy the selected area, then clear it, as one undo
function editorCut()
{
const area = editorSelection;
if (!editorCopy()) return false;
editorSelection = area;
editorClear();
editorSelection = undefined;
return true;
}
// the last copied stamp back into the brush
function editorPaste()
{
if (!editorClipboard) return false;
editorBrush = editorClipboard;
return true;
}
///////////////////////////////////////////////////////////////////////////////
// panel
let editorPanel, editorPanelParts;
// the list of keys ? shows
const editorHelpLines =
[
'Left: paint · Shift+Left: line from the last tile',
'Right click: pick a tile · Right drag: select',
'Left drag a selection: move it · Esc or right click: put it back',
'Middle drag or Space+drag: pan · Wheel or pinch: zoom',
'1-9: layer · Esc: play and edit · 0: exit the editor',
'F: fill · Delete: clear the selection',
'Ctrl+C / X / V: copy, cut, paste · Ctrl+Z / Y: undo, redo',
'R, Shift+R: turn · M: mirror · E: erase · G: grid · ?: keys',
'Objects layer: left places or selects, drag moves, right picks or box-selects, Delete removes, Ctrl+C / X / V',
];
// the hint line, for what is held and whether there is a selection
function editorHint()
{
if (keyIsDown('Space')) return 'Drag to pan';
if (editorObjectLayer)
return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
'Left place / select · drag moves · Right pick / drag select · Delete removes';
if (editorSelection)
return 'Selection: drag moves · F fill · Delete clear · Ctrl+C copy · Ctrl+X cut · click to clear';
if (keyIsDown('ShiftLeft') || keyIsDown('ShiftRight')) return 'Shift: line from the last tile';
return 'Left paint · Right pick / drag select · F fill · Space or middle drag pans · ? keys';
}
// what the brush is, in words
function editorBrushLabel()
{
if (editorObjectLayer)
{
const brush = editorObjectBrush;
return !brush ? 'Brush: none' : brush.length > 1 ? `Brush: ${brush.length} objects` : `Brush: ${brush[0].type}`;
}
const {width, height, grids} = editorBrush, t = editorBrushTile();
if (t)
return `Brush: tile ${t.tile}` + (t.direction ? `, turned ${t.direction * 90}°` : '') +
(t.mirror ? ', mirrored' : '');
if (width === 1 && height === 1 && grids.length === 1) return grids[0][0] === 0 ? 'Brush: Erase' : 'Brush: empty';
const objects = editorBrush.objects?.length;
return `Brush: ${width}x${height} stamp` + (grids.length > 1 ? `, ${grids.length} layers` : '') +
(objects ? `, ${objects} object${objects > 1 ? 's' : ''}` : '');
}
function editorElement(tag, parent, style='', text='')
{
const element = document.createElement(tag);
element.style.cssText = style;
element.textContent = text;
parent?.appendChild(element);
return element;
}
// the panel, made the first time the editor opens, on the left so it can sit beside the tweakables panel
function editorPanelInit()
{
editorPanel = editorElement('div', document.body,
'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
'border-radius:4px;z-index:9999;user-select:none');
// a click or touch on the panel is not the game's, a mouse up still goes on so a drag can let go
for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
editorPanel.addEventListener(type, (e)=> e.stopPropagation());
editorPanel.addEventListener('mouseenter', ()=> editorMouseOnPanel = true);
editorPanel.addEventListener('mouseleave', ()=> editorMouseOnPanel = false);
const row = ()=> editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0');
const button = (parent, text, onclick, title='')=>
{
const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
b.title = title;
return b;
};
editorElement('div', editorPanel, 'font-weight:bold', 'Level Editor');
const top = row();
button(top, 'Play', ()=> editorPlay(editorCameraPos), 'Esc, and Esc again comes back to the editor');
const restart = button(top, 'Restart', editorRestart, 'Rebuild the level and play it');
button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
const undo = row();
button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?');
// a file that changed under its autosave
const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
editorElement('div', pending, '', 'The level file changed since your autosaved edits');
const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
button(pendingRow, 'Apply edits', ()=> editorApplyPending(editorRecord()));
button(pendingRow, 'Drop them', ()=> editorDiscardPending(editorRecord()));
const pendingUnfit = editorElement('div', pending, 'color:#fb8;margin-top:4px',
'They do not fit the file now, its layers or size changed; they are kept until dropped');
// the layers as numbered buttons, made again when layers come or go, and All Layers
const layerRow = editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
const allLayers = editorElement('button', editorPanel, 'width:100%;padding:3px;cursor:pointer;margin-bottom:4px');
allLayers.title = 'Delete, Ctrl+C and Ctrl+X act on every layer of the map';
allLayers.onclick = ()=> { editorAllLayers = !editorAllLayers; allLayers.blur(); };
const turns = row();
button(turns, 'Turn', ()=> editorBrush = editorStampTurn(editorBrush), 'R, Shift+R turns back');
button(turns, 'Mirror', ()=> editorBrush = editorStampMirror(editorBrush), 'M');
// the layer's tiles in a grid, a click picks one
const palette = editorElement('canvas', editorPanel,
'display:block;background:#333;cursor:crosshair;margin:4px 0');
palette.onclick = (e)=>
{
const cell = editorPaletteCell, slot = (e.offsetY / cell | 0) * editorPaletteColumns + (e.offsetX / cell | 0);
const slots = editorObjectLayer ? objectLayersTypes.size : editorPaletteTiles(editorLayer).length + 1;
slot < slots && e.offsetX < editorPaletteColumns * cell && editorPalettePick(slot);
};
const brush = editorElement('div', editorPanel, 'color:#ccc;margin:2px 0');
const properties = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px');
const file = row();
// Save says so for a moment when it saved, Save As shows where a page can write files
const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
const save = button(file, 'Save', ()=> editorSave(editorRecord()).then(saved(save, 'Save')),
'Save, to the file picked the first time, or a download');
const saveAs = button(file, 'Save As', ()=> editorSave(editorRecord(), true).then(saved(saveAs, 'Save As')),
'Save As, to a file picked again');
/** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
// the Advanced section, shown by its heading: Play from mouse, the level's size, Reset to file
const advancedToggle = editorElement('button', editorPanel,
'width:100%;padding:3px;cursor:pointer;margin-top:4px;text-align:left');
advancedToggle.onclick = ()=> { editorAdvanced = !editorAdvanced; advancedToggle.blur(); };
const advanced = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px');
const playFromLabel = editorElement('label', advanced, 'display:flex;gap:6px;align-items:center;margin:2px 0');
playFromLabel.title = 'Escape starts play with the player at the mouse, Play at the view center';
const playFrom = editorElement('input', playFromLabel);
playFrom.type = 'checkbox';
playFrom.onchange = ()=> { editorPlayFromMouse = playFrom.checked; playFrom.blur(); };
editorElement('span', playFromLabel, '', 'Play from mouse');
const sizeRow = editorElement('div', advanced, 'display:flex;gap:4px;align-items:center;margin:4px 0');
sizeRow.title = 'The level grows or shrinks at the right and top, can be undone';
editorElement('span', sizeRow, '', 'Size');
const sizeInput = ()=>
{
const input = editorElement('input', sizeRow, 'width:48px;background:#333;color:#eee');
input.type = 'number';
input.min = input.step = '1';
input.onkeydown = (e)=> { e.key === 'Enter' && resizeLevel(); }; // returning false would block typing
return input;
};
const sizeX = sizeInput();
editorElement('span', sizeRow, '', '×');
const sizeY = sizeInput();
const resize = editorElement('button', sizeRow, 'flex:1;padding:3px;cursor:pointer', 'Resize');
const resizeLevel = ()=>
{
// up to 1000 a side from here, editorResize takes any size from code
const size = (input)=> clamp(parseInt(input.value) || 1, 1, 1e3);
editorResize(editorRecord(), size(sizeX), size(sizeY));
resize.blur(); sizeX.blur(); sizeY.blur(); // the fields show the size again
};
resize.onclick = resizeLevel;
const reset = editorElement('button', advanced, 'width:100%;padding:3px;cursor:pointer;margin-top:4px',
'Reset to file');
reset.title = 'Put the level back to the file it was loaded from, or last saved to, can be undone';
reset.onclick = ()=> { editorRevert(editorRecord()); reset.blur(); };
const storage = editorElement('div', editorPanel, 'color:#f86;margin-top:4px',
'Autosave failed, storage is full: Save to a file');
const status = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;min-height:1em');
const hint = editorElement('div', editorPanel, 'color:#8ab;margin-top:4px');
const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
for (const line of editorHelpLines)
editorElement('div', help, 'margin:2px 0', line);
button(help, 'Close', ()=> editorHelp = false, '?');
editorPanelParts = {pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
}
// the palette's cell size in pixels and how many to a row
const editorPaletteCell = 30, editorPaletteColumns = 8;
// the tiles a layer's palette shows, each its index and the tile info it draws with: the game's
// levelEditor.paletteTiles, or every tile up to the image's edge without the blank ones at the end, a tile of one
// flat color; found once for each layer and list
function editorPaletteTiles(layer)
{
const live = layer?.live, image = live?.tileInfo?.textureInfo?.image, list = levelEditor.paletteTiles;
if (list && live)
{
if (layer.palette?.list !== list)
layer.palette = {list, tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)}))};
return layer.palette.tiles;
}
if (!image) return [];
if (layer.palette?.image === image) return layer.palette.tiles;
const tiles = [];
for (let i = 0; i < 4096; ++i)
{
const t = editorTileInfo(live, i);
if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
tiles.push({tile: i, tileInfo: t});
}
try
{
// an image from a file page can not be read back, then every tile is kept
const context = createCanvasContext(image.width, image.height, true);
context.drawImage(image, 0, 0);
const blank = (t)=>
{
const {pos, size} = t.tileInfo, pixels = new Uint32Array(context.getImageData(pos.x, pos.y, size.x,
size.y).data.buffer);
return pixels.every((p)=> p === pixels[0]);
};
while (tiles.length > 1 && blank(tiles.at(-1)))
tiles.pop();
}
catch {}
layer.palette = {image, tiles};
return tiles;
}
// draw the palette of object types, each its icon or the start of its name, the brush's type outlined
function editorPaletteDrawObjects(canvas)
{
const cell = editorPaletteCell, columns = editorPaletteColumns, types = [...objectLayersTypes];
canvas.style.display = types.length ? '' : 'none';
canvas.width = columns * cell;
canvas.height = ceil(types.length / columns) * cell;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.font = '10px monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
types.forEach(([name, {tileInfo}], i)=>
{
const x = i % columns * cell, y = (i / columns | 0) * cell, image = tileInfo?.textureInfo?.image;
if (image)
context.drawImage(image, tileInfo.pos.x, tileInfo.pos.y, tileInfo.size.x, tileInfo.size.y,
x + 2, y + 2, cell - 4, cell - 4);
else
{
context.fillStyle = '#ccc';
context.fillText(name.slice(0, 4), x + cell / 2, y + cell / 2);
}
if (editorObjectBrush?.length !== 1 || editorObjectBrush[0].type !== name) return;
context.strokeStyle = '#4af';
context.lineWidth = 2;
context.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
});
}
// draw the palette again, for a new layer or brush tile
function editorPaletteDraw(canvas, layer)
{
const tiles = editorPaletteTiles(layer), cell = editorPaletteCell, columns = editorPaletteColumns;
const image = layer?.live.tileInfo?.textureInfo?.image, slots = tiles.length + 1;
canvas.style.display = tiles.length ? '' : 'none';
canvas.width = columns * cell;
canvas.height = ceil(slots / columns) * cell;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
// slot 0 is the Erase brush, a red cross, the tiles follow
context.strokeStyle = '#e44';
context.lineWidth = 2;
context.strokeRect(4, 4, cell - 8, cell - 8);
context.beginPath();
context.moveTo(8, 8), context.lineTo(cell - 8, cell - 8);
context.moveTo(cell - 8, 8), context.lineTo(8, cell - 8);
context.stroke();
tiles.forEach(({tileInfo: {pos, size}}, i)=>
{
const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell;
context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4);
});
// the brush's slot outlined, when it is one tile the palette shows or Erase
const t = editorBrushTile(), erase = !t && editorBrush.width === 1 && editorBrush.height === 1 &&
editorBrush.grids[0][0] === 0, shown = t ? tiles.findIndex((p)=> p.tile === t.tile) : -1;
const selected = erase ? 0 : shown >= 0 ? shown + 1 : -1;
if (selected < 0) return;
context.strokeStyle = '#4af';
context.strokeRect(selected % columns * cell + 1, (selected / columns | 0) * cell + 1, cell - 2, cell - 2);
}
// while playing in an editing session, a tag in the corner says how to get back to the editor
let editorTag;
function editorTagUpdate()
{
const show = editorSession && !editorIsOpen;
if (!show && !editorTag) return;
editorTag ||= editorElement('div', document.body, 'position:fixed;bottom:8px;left:8px;padding:4px 8px;' +
'background:#111d;color:#eee;font:12px monospace;border-radius:4px;z-index:9999;pointer-events:none');
editorTag.textContent = `${debugKey === 'Escape' ? 'Esc' : debugKey}: edit level`;
editorTag.style.display = show ? '' : 'none';
}
// shows or hides the panel, and shows what changed since the last frame
function editorPanelUpdate()
{
editorTagUpdate();
if (!editorIsOpen)
{
editorPanel && (editorPanel.style.display = 'none');
return;
}
editorPanel || editorPanelInit();
editorPanel.style.display = '';
const p = editorPanelParts, layers = editorLayers();
// the layer buttons, made again when layers came or went
if (!p.layers || layers.length !== p.layers.length || layers.some((layer, i)=> layer !== p.layers[i]))
{
p.layers = layers;
p.layerRow.replaceChildren(...layers.map((layer, i)=>
{
// its number key and name, cut short when the row runs out of room, the whole name in its tooltip
const name = layer.isObjects ? layer.name : layer.source.name;
const b = editorElement('button', undefined, 'flex:1;min-width:28px;padding:3px;cursor:pointer;' +
'white-space:nowrap;overflow:hidden;text-overflow:ellipsis', (i < 9 ? String(i + 1) : '·') +
(name ? ' ' + name : ''));
b.title = `${name || 'Layer ' + (i + 1)} (${layer.record.fileName})`;
b.onclick = ()=> { editorSelectLayer(layer); b.blur(); };
return b;
}));
}
layers.forEach((layer, i)=> p.layerRow.children[i].style.outline =
layer === (editorObjectLayer ?? editorLayer) ? '2px solid #4af' : '');
p.allLayers.textContent = editorAllLayers ? 'All Layers: on' : 'All Layers: off';
p.brush.textContent = editorBrushLabel();
p.hint.textContent = editorHint();
p.help.style.display = editorHelp ? '' : 'none';
p.storage.style.display = editorSaveFailed ? '' : 'none';
p.restart.style.display = levelEditor.onRestart ? '' : 'none';
p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
p.advanced.style.display = editorAdvanced ? '' : 'none';
p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
p.playFrom.checked = editorPlayFromMouse;
// the level's size, shown until a field is being typed in; without the Restart hook it can not change
const record = editorRecord(), map = record?.map;
const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
p.sizeRow.style.display = map ? 'flex' : 'none';
if (map && !p.sizeRow.contains(document.activeElement))
{
p.sizeX.value = String(map.width);
p.sizeY.value = String(map.height);
}
p.sizeX.disabled = p.sizeY.disabled = p.resize.disabled = !canResize;
p.resize.title = canResize ? '' : 'Resizing needs levelEditor.onRestart, to make the level again';
p.turns.style.display = editorObjectLayer ? 'none' : ''; // the tile brush's, not an object layer's
p.pending.style.display = record?.pending ? '' : 'none';
p.pendingUnfit.style.display = record?.pendingUnfit ? '' : 'none';
p.reset.style.display = record?.synthetic ? 'none' : ''; // a layer made in code has no file
// the palette, drawn again when the layer or the brush changed, a change makes a new brush
const live = editorLayer?.live;
const paletteLayer = editorObjectLayer ?? editorLayer, paletteBrush = editorObjectLayer ? editorObjectBrush : editorBrush;
if (p.paletteLayer !== paletteLayer || p.paletteBrush !== paletteBrush)
{
p.paletteLayer = paletteLayer;
p.paletteBrush = paletteBrush;
editorObjectLayer ? editorPaletteDrawObjects(p.palette) : editorPaletteDraw(p.palette, editorLayer);
}
editorPropertiesUpdate(p.properties);
const t = editorHover && editorGidToTile(editorLayer.source.data[editorHover.x +
(live.size.y - 1 - editorHover.y) * live.size.x]);
p.status.textContent = editorHover ? `cell ${editorHover.x}, ${editorHover.y}` +
(t ? ` tile ${t.tile}` : '') : '';
}
///////////////////////////////////////////////////////////////////////////////
// keys and wheel
// letter shortcuts, by the key's label so they follow the letter printed on any keyboard layout, each called
// with whether Shift is held; one that returns false did nothing, and leaves the key to the browser
const editorKeys =
{
r: (shift)=> editorObjectLayer ? false : editorBrush = editorStampTurn(editorBrush, shift),
m: ()=> editorObjectLayer ? false : editorBrush = editorStampMirror(editorBrush),
e: ()=> editorObjectLayer ? false : editorBrush = editorStampTile(0),
g: ()=> { editorGrid = !editorGrid; },
'?': ()=> { editorHelp = !editorHelp; },
f: ()=> editorObjectLayer ? false : editorFill(),
Delete: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(),
Backspace: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(),
};
const editorCtrlKeys =
{
z: (shift)=> editorUndo(shift),
y: ()=> editorUndo(true),
c: ()=> editorObjectLayer ? editorCopyObjects() : editorCopy(),
x: ()=> editorObjectLayer ? editorCutObjects() : editorCut(),
v: ()=> editorObjectLayer ? editorPasteObjects() : editorPaste(),
};
// a field that takes typed keys, as the engine counts one: a checkbox, slider or button left with focus, as a
// tweak is, does not take them
function editorIsTextField(target)
{
const field = target?.closest?.('input,textarea,select,[contenteditable]');
return !!field && !(field.tagName === 'INPUT' &&
/^(button|checkbox|color|file|image|radio|range|reset|submit)$/i.test(field.type));
}
// the editor's own key listener, a key typed into a field is the field's, and Alt with Ctrl is AltGr typing
// a character; a letter that is not a Latin one, on a Cyrillic or Greek keyboard, goes by the key's position
function editorOnKeyDown(e)
{
if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return;
if (editorSelectionDrag && !(debugKey && e.code === debugKey))
{
// a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there;
// the debug key, Escape, is left to the update, which puts the selection back
editorStrokeEnd();
editorSelectionDrag = undefined;
}
let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (key.length === 1 && !/[a-z?]/.test(key))
key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
const action = (e.ctrlKey || e.metaKey ? editorCtrlKeys : editorKeys)[key];
if (action && action(e.shiftKey) !== false)
e.preventDefault();
}
// the wheel zooms toward the mouse by how far it moved; a trackpad pinch is a wheel with ctrlKey, which the engine
// leaves alone
function editorOnWheel(e)
{
if (!editorIsOpen || editorIsTextField(e.target)) return;
const pixels = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
editorZoom(Math.exp(-pixels * (e.ctrlKey ? .01 : .002)));
}
// zoom by a factor, keeping the point under the mouse where it is
function editorZoom(factor)
{
editorApplyCamera();
const before = screenToWorld(mousePosScreen);
editorCameraScale = clamp(editorCameraScale * factor, 1, 1e3);
editorApplyCamera();
editorCameraPos = editorCameraPos.add(before.subtract(screenToWorld(mousePosScreen)));
editorApplyCamera();
}
///////////////////////////////////////////////////////////////////////////////
// objects in the panel and the level
// the position a click places or drags to, a cell center unless the grid is off
function editorSnap(pos) { return editorGrid ? pos.floor().add(vec2(.5)) : pos.copy(); }
// if the properties box has an input for a default's type, a number, boolean, string or Color
const editorPropertyEditable = (value)=>
['number', 'boolean', 'string'].includes(typeof value) || isColor(value) || isVector2(value);
// set a property of the one selected object, from the properties box, as one undo
function editorSetSelectedProperty(name, value)
{
const selected = editorSelectedObjects(), object = selected[0];
const type = object && objectLayersTypes.get(object.type || object.class), defaultValue = type?.defaults[name];
if (selected.length !== 1 || !editorPropertyEditable(defaultValue) ||
(isColor(defaultValue) ? !isColor(value) : isVector2(defaultValue) ? !isVector2(value) :
typeof value !== typeof defaultValue)) return false;
editorStrokeEnd();
editorChangeObjects(editorObjectLayer, (list)=>
editorObjectSetProperty(list.find((o)=> o.id === object.id), name, value, type.defaults[name]));
editorStrokeEnd();
return true;
}
// if the game has nothing where the level puts an object: gone in play, moved in play, or not a game object, like a
// player start, so the editor draws its icon there
function editorObjectIsGhost(layer, object)
{
const made = layer.instances.get(object.id);
return !made || made.destroyed || !isVector2(made.pos) ||
made.pos.distance(editorObjectPos(layer.record, object)) > .01;
}
// a world box's outline
function editorDrawBox(center, size, color, width)
{
const half = size.scale(.5), corners = [vec2(-1, -1), vec2(1, -1), vec2(1, 1), vec2(-1, 1)]
.map((c)=> center.add(c.multiply(half)));
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], width, color, undefined, 0, glEnable, false));
}
// an object type's icon, or a box with its name when it has none
function editorDrawObjectIcon(name, pos, size, alpha)
{
const tileInfo = objectLayersTypes.get(name)?.tileInfo;
if (tileInfo)
drawTile(pos, size, tileInfo, hsl(0, 0, 1, alpha), 0, undefined, undefined, glEnable, false);
else
{
drawRect(pos, size, hsl(0, 0, 0, alpha * .6), undefined, undefined, false);
drawText(name ?? '?', pos, min(size.x, size.y) * .3, hsl(0, 0, 1, alpha));
}
}
// the objects of the object layer being edited: each one's outline, yellow when selected, a ghost of one the game does
// not have where the level puts it, the box being dragged, and the brush's objects at the mouse
function editorRenderObjects(thin)
{
const layer = editorObjectLayer, record = layer.record;
for (const object of layer.group?.objects ?? [])
{
const pos = editorObjectPos(record, object), size = editorObjectSize(layer, object);
if (editorObjectIsGhost(layer, object))
editorDrawObjectIcon(object.type || object.class, pos, size, .5);
const selected = editorObjectSelection.has(object.id);
editorDrawBox(pos, size, selected ? hsl(.15, 1, .6) : hsl(.55, 1, .6, .6), thin * (selected ? 3 : 1.5));
}
const box = editorObjectBox;
if (box)
editorDrawBox(box.a.add(box.b).scale(.5), box.b.subtract(box.a).abs(), hsl(.15, 1, .6), thin * 2);
else if (editorObjectBrush && !editorObjectDrag && !editorObjectSelection.size && !mouseIsDown(1))
{
const pos = editorSnap(screenToWorld(mousePosScreen));
for (const object of editorObjectBrush)
editorDrawObjectIcon(object.type, pos.add(object.offset), vec2(1), .5);
}
}
// the properties box: an input for each default of the one selected object's type, made again when the selection or
// the object changes, but not while one of its inputs is being typed in
function editorPropertiesUpdate(box)
{
const selected = editorObjectLayer ? editorSelectedObjects() : [], object = selected.length === 1 && selected[0];
const type = object && objectLayersTypes.get(object.type || object.class);
const key = type ? editorObjectLayer.record.fileName + object.id + JSON.stringify(object.properties) : '';
box.style.display = key ? '' : 'none';
if (box.dataset.key === key || box.contains(document.activeElement)) return;
box.dataset.key = key;
box.replaceChildren();
if (!type) return;
editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type || object.class} ${object.id}`);
const values = objectLayersProperties(type, object);
for (const [name, defaultValue] of Object.entries(type.defaults))
{
const row = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
editorElement('span', row, 'flex:1', name);
const value = values[name], input = editorElement('input', row, 'width:110px;background:#222;color:#eee');
const set = (v)=> { editorSetSelectedProperty(name, v); input.blur(); };
if (typeof defaultValue === 'boolean')
{
input.type = 'checkbox';
input.checked = !!value;
input.onchange = ()=> set(input.checked);
}
else if (isColor(defaultValue))
{
input.type = 'color';
input.value = value.toString(false);
input.onchange = ()=> { const color = rgb().setHex(input.value); color.a = value.a; set(color); };
}
else if (typeof defaultValue === 'number')
{
input.type = 'number';
input.step = Number.isInteger(defaultValue) ? '1' : 'any';
input.value = String(value);
input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
}
else if (typeof defaultValue === 'string')
{
input.type = 'text';
input.value = String(value ?? '');
input.onchange = ()=> set(input.value);
}
else if (isVector2(defaultValue))
{
// x and y, the row's second input for y
const y = editorElement('input', row, 'width:52px;background:#222;color:#eee');
input.style.width = '52px';
for (const [field, v] of [[input, value.x], [y, value.y]])
{
field.type = 'number';
field.step = 'any';
field.value = String(v);
field.onchange = ()=>
{
// both checked before a vector is made of them, an emptied or half typed field shows its value again
const px = parseFloat(input.value), py = parseFloat(y.value);
if (isNumber(px) && isNumber(py))
set(vec2(px, py));
else
input.value = String(value.x), y.value = String(value.y);
field.blur();
};
}
}
else
{
// a type it has no input for, an array say, shown as it is
input.readOnly = true;
input.value = String(value);
}
}
}
///////////////////////////////////////////////////////////////////////////////
// plugin
function editorUpdate()
{
// Escape while dragging a selection puts it back, and stays in the editor
if (editorSelectionDrag && debugKey && keyWasPressed(debugKey))
{
inputClearKey(debugKey);
editorSelectionDragCancel();
return;
}
// in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
// overlay waits till the session ends
if (editorSession && debugKey && keyWasPressed(debugKey))
{
inputClearKey(debugKey);
if (editorIsOpen)
{
editorApplyCamera(); // the mouse is read through the editor's view
editorPlay(screenToWorld(mousePosScreen));
}
else
editorSetOpen(true);
return;
}
if (!editorIsOpen) return;
editorApplyCamera();
// 0 exits the editor, the overlay's 0 does it while the overlay is open; cleared so debugKeysAlways
// does not toggle it back in the same step
if (!debugOverlay && keyWasPressed('Digit0'))
{
inputClearKey('Digit0');
levelEditor.close();
return;
}
// 1 to 9 pick a layer from the back; taken, so debugKeysAlways does not also flip the debug views, the overlay
// keeps them while it is open
for (let i = 1; !debugOverlay && i <= 9; ++i)
{
if (!keyWasPressed('Digit' + i)) continue;
inputClearKey('Digit' + i);
const layer = editorLayers()[i - 1];
layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
}
// the middle button, or Space with the left, drags the view
const space = keyIsDown('Space');
if (mouseIsDown(1) || space && mouseIsDown(0))
editorCameraPos = editorCameraPos.subtract(screenToWorldDelta(mouseDeltaScreen));
editorApplyCamera();
editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
if (editorObjectLayer)
return editorUpdateObjects(space);
const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
editorHover = layer && !editorMouseOnPanel && editorCellAt(layer.live, mouse); // over the panel, a line starts again
// a right press while dragging the selection puts it back, and is taken so it does not pick or select
if (editorSelectionDrag && mouseWasPressed(2))
{
inputClearKey(2);
editorSelectionDragCancel();
}
// the right button: a click picks the tile under the mouse, or clears the selection off the layer, a drag
// selects an area; a release within half a cell of the press, or 4 pixels zoomed far out, is a click
if (mouseWasPressed(2) && layer)
editorRightPress = {screen: mousePosScreen.copy(), cell: editorCellClamped(layer.live, mouse), drag: false};
const press = editorRightPress;
if (press && layer)
{
press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2); // a few pixels of wobble at any zoom
if (press.drag)
editorSelection = editorArea(press.cell, editorCellClamped(layer.live, mouse));
if (!mouseIsDown(2))
{
if (!press.drag)
editorHover ? editorPick(layer, editorHover) : editorSelection = undefined;
editorRightPress = undefined;
}
}
else
editorRightPress = undefined;
// the left button paints; a press in the selection drags it, the first press outside it only clears it, Shift
// draws a line from the last tile placed; a quick click let go before this step still reads as pressed
if (mouseWasPressed(0) && !space && editorSelection)
{
const {min: a, max: b} = editorSelection, cell = editorHover;
if (cell && !layer.record.pending && cell.x >= a.x && cell.y >= a.y && cell.x <= b.x && cell.y <= b.y)
editorSelectionDragStart(layer, cell);
else
editorSelection = undefined;
editorLeftSpent = true;
}
if (editorSelectionDrag && layer)
editorSelectionDragTo(editorCellClamped(layer.live, mouse));
const left = mouseIsDown(0) || mouseWasPressed(0);
if (!editorHover)
editorLastCell = undefined; // off the layer, coming back in starts the line again
else if (left && !space && !editorLeftSpent && !editorRightPress)
{
const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
if (mouseWasPressed(0) && shift && last?.layer === layer)
{
editorPaintBetween(layer, last.pos, editorHover);
editorLastCell = editorHover;
}
else
editorPaintLine(layer, editorHover);
if (mouseWasPressed(0) && editorBrush.objects)
editorPlaceStampObjects(layer, editorHover, editorBrush); // once a click, not along the drag
}
if (!mouseIsDown(0))
{
editorStrokeEnd();
editorLastCell = editorSelectionDrag = undefined;
editorLeftSpent = false;
}
}
// switch to play and have the game rebuild its level from the changed map, when it has a hook for that
function editorRestart()
{
if (!levelEditor.onRestart) return;
editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
levelEditor.onRestart();
}
// called by the engine before the camera goes to WebGL, so the level is drawn with the editor's view, a game may move
// the camera from gameUpdatePost, which runs while paused
function editorPreRender() { editorIsOpen && editorApplyCamera(); }
// the layer's edge, or the map's for a level of objects alone, a grid when zoomed in, the map's tiles the layer does not
// show, and the brush under the mouse
function editorRender()
{
headlessMode || editorPanelUpdate();
if (!editorIsOpen || headlessMode) return;
const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
if (!layer && !editorObjectLayer) return;
// the area drawn, the tile layer's, or for a level of objects alone the map's, where objectLayersLoad puts it
const map = editorRecord().map, live = layer?.live, source = layer?.source;
const pos = live ? live.pos : vec2(), size = live ? live.size : vec2(map.width, map.height);
const {x: width, y: height} = size;
// the cells on screen
const low = screenToWorld(vec2(0, mainCanvasSize.y)).subtract(pos);
const high = screenToWorld(vec2(mainCanvasSize.x, 0)).subtract(pos);
const x0 = max(0, floor(low.x)), y0 = max(0, floor(low.y));
const x1 = min(width, ceil(high.x)), y1 = min(height, ceil(high.y));
// ghosts, cells the map has that the layer does not show: markers a game made objects of, tiles broken in play
const ghost = hsl(0, 0, 1, .4);
for (let x = x0; live && !editorObjectLayer && x < x1; ++x)
for (let y = y0; y < y1; ++y)
{
const t = editorGidToTile(source.data[x + (height - 1 - y) * width]);
if (t && live.getData(vec2(x, y)).tile === undefined)
drawTile(live.pos.add(vec2(x + .5, y + .5)), vec2(1), editorTileInfo(live, t.tile), ghost,
t.direction * PI/2, t.mirror, undefined, glEnable, false);
}
// a grid once the cells are big enough to see one
const line = hsl(0, 0, 1, .12), thin = 1 / editorCameraScale;
if (editorGrid && editorCameraScale >= 12)
{
for (let x = x0; x <= x1; ++x)
drawLine(pos.add(vec2(x, y0)), pos.add(vec2(x, y1)), thin, line, undefined, 0, glEnable, false);
for (let y = y0; y <= y1; ++y)
drawLine(pos.add(vec2(x0, y)), pos.add(vec2(x1, y)), thin, line, undefined, 0, glEnable, false);
}
// the layer's edge
const outline = (a, b, color, width)=>
{
const corners = [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)];
corners.forEach((c, i)=> drawLine(pos.add(c), pos.add(corners[(i + 1) % 4]), width, color, undefined, 0, glEnable, false));
};
outline(vec2(), size, hsl(.55, 1, .6, .8), thin * 2);
// the selection, and the line Shift would draw
if (editorSelection && !editorObjectLayer)
outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
// the brush where it would paint, the Erase brush's cells in red
if (editorHover)
{
const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
for (let y = h; y--;)
for (let x = w; x--;)
{
const gid = grid[x + y * w], t = editorGidToTile(gid);
const center = live.pos.add(editorHover).add(vec2(x + .5, y + .5));
if (t)
drawTile(center, vec2(1), editorTileInfo(live, t.tile), hsl(0, 0, 1, .7), t.direction * PI/2, t.mirror, undefined, glEnable, false);
else if (gid === 0)
drawRect(center, vec2(1), hsl(0, 1, .5, .3), undefined, undefined, false);
}
drawRect(live.pos.add(editorHover).add(vec2(w / 2, h / 2)), vec2(w, h), hsl(.55, 1, .6, .25), undefined, undefined, false);
}
editorObjectLayer && editorRenderObjects(thin);
}
debug && engineAddPlugin(editorUpdate, editorRender);
// the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
if (debug && globalThis.document?.addEventListener)
{
document.addEventListener('keydown', editorOnKeyDown);
document.addEventListener('wheel', editorOnWheel, {passive: true});
}