tilemap-editor
Version:
A fat-free tilemap editor with zero dependencies and a scalable, mobile-friendly interface!
1,107 lines (1,036 loc) âĸ 80 kB
JavaScript
// @ts-check
(function (root, factory) {
// @ts-ignore
if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
// CommonJS
factory(exports);
} else {
// Browser globals
// @ts-ignore
factory((root.TilemapEditor = {}));
}
})(typeof self !== 'undefined' ? self : this, function (exports) {
// Call once on element to add behavior, toggle on/off isDraggable attr to enable
const draggable = ({element, onElement = null, isDrag = false, onDrag = null,
limitX = false, limitY = false, onRelease = null}) => {
element.setAttribute("isDraggable", isDrag);
let isMouseDown = false;
let mouseX;
let mouseY;
let elementX = 0;
let elementY = 0;
const onMouseMove = (event) => {
if (!isMouseDown || element.getAttribute("isDraggable") === "false") return;
const deltaX = event.clientX - mouseX;
const deltaY = event.clientY - mouseY;
// element.style.position = "relative"
if(!limitX) element.style.left = elementX + deltaX + 'px';
if(!limitY) element.style.top = elementY + deltaY + 'px';
console.log("DRAGGING", {deltaX, deltaY, x: elementX + deltaX, y:elementY + deltaY})
if(onDrag) onDrag({deltaX, deltaY, x: elementX + deltaX, y:elementY + deltaY, mouseX, mouseY});
}
const onMouseDown = (event) => {
if(element.getAttribute("isDraggable") === "false") return;
mouseX = event.clientX;
mouseY = event.clientY;
console.log("MOUSEX", mouseX)
isMouseDown = true;
}
const onMouseUp = () => {
if(!element.getAttribute("isDraggable") === "false") return;
isMouseDown = false;
elementX = parseInt(element.style.left) || 0;
elementY = parseInt(element.style.top) || 0;
if(onRelease) onRelease({x:elementX,y:elementY})
}
(onElement || element).addEventListener('pointerdown', onMouseDown);
document.addEventListener('pointerup', onMouseUp);
document.addEventListener('pointermove', onMouseMove);
}
const drawGrid = (w, h,ctx, step = 16, color='rgba(0,255,217,0.5)') => {
ctx.strokeStyle = color;
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = 0; x < w + 1; x += step) {
ctx.moveTo(x, 0.5);
ctx.lineTo(x, h + 0.5);
}
for (let y = 0; y < h +1; y += step) {
ctx.moveTo(0, y + 0.5);
ctx.lineTo(w, y + 0.5);
}
ctx.stroke();
}
const toBase64 = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
const decoupleReferenceFromObj = (obj) => JSON.parse(JSON.stringify(obj));
const getHtml = (width, height) =>{
return `
<div id="tilemapjs_root" class="card tilemapjs_root">
<a id="downloadAnchorElem" style="display:none"></a>
<div class="tileset_opt_field header">
<div class="menu file">
<span> File </span>
<div class="dropdown" id="fileMenuDropDown">
<a class="button item button-as-link" href="#popup2">About</a>
<div id="popup2" class="overlay">
<div class="popup">
<h4>Tilemap editor</h4>
<a class="close" href="#">×</a>
<div class="content">
<div>Created by Todor Imreorov (blurymind@gmail.com)</div>
<br/>
<div><a class="button-as-link" href="https://github.com/blurymind/tilemap-editor">Project page (Github)</a></div>
<div><a class="button-as-link" href="https://ko-fi.com/blurymind">Donate page (ko-fi)</a></div>
<br/>
<div>Instructions:</div>
<div>right click on map - picks tile</div>
<div>mid-click - erases tile</div>
<div>left-click adds tile</div>
<div>right-click on tileset - lets you change tile symbol or metadata</div>
<div>left-click - selects tile </div>
</div>
</div>
</div>
</div>
</div>
<div>
<div id="toolButtonsWrapper" class="tool_wrapper">
<input id="tool0" type="radio" value="0" name="tool" checked class="hidden"/>
<label for="tool0" title="paint tiles" data-value="0" class="menu">
<div id="flipBrushIndicator">đī¸</div>
<div class="dropdown">
<div class="item nohover">Brush tool options</div>
<div class="item">
<label for="toggleFlipX" class="">Flip tile on x</label>
<input type="checkbox" id="toggleFlipX" style="display: none">
<label class="toggleFlipX"></label>
</div>
</div>
</label>
<input id="tool1" type="radio" value="1" name="tool" class="hidden"/>
<label for="tool1" title="erase tiles" data-value="1">đī¸</label>
<input id="tool2" type="radio" value="2" name="tool" class="hidden"/>
<label for="tool2" title="pan" data-value="2">â</label>
<input id="tool3" type="radio" value="3" name="tool" class="hidden"/>
<label for="tool3" title="pick tile" data-value="3">đ¨</label>
<input id="tool4" type="radio" value="4" name="tool" class="hidden"/>
<label for="tool4" title="random from selected" data-value="4">đ˛</label>
<input id="tool5" type="radio" value="5" name="tool" class="hidden"/>
<label for="tool5" title="fill on layer" data-value="5">đ</label>
</div>
</div>
<div class="tool_wrapper">
<label id="undoBtn" title="Undo">âŠī¸ī¸</label>
<label id="redoBtn" title="Redo">đī¸</label>
<label id="zoomIn" title="Zoom in">đī¸+</label>
<label id="zoomOut" title="Zoom out">đī¸-</label>
<label id="zoomLabel">ī¸</label>
</div>
<div>
<button class="primary-button" id="confirmBtn">"apply"</button>
</div>
</div>
<div class="card_body">
<div class="card_left_column">
<details class="details_container sticky_left" id="tilesetDataDetails" open="true">
<summary >
<span id="mapSelectContainer">
| <select name="tileSetSelectData" id="tilesetDataSel" class="limited_select"></select>
<button id="replaceTilesetBtn" title="replace tileset">r</button>
<input id="tilesetReplaceInput" type="file" style="display: none" />
<button id="addTilesetBtn" title="add tileset">+</button>
<input id="tilesetReadInput" type="file" style="display: none" />
<button id="removeTilesetBtn" title="remove">-</button>
</span>
</summary>
<div>
<div class="tileset_opt_field">
<span>Tile size:</span>
<input type="number" id="cropSize" name="crop" placeholder="32" min="1" max="128">
</div>
<div class="tileset_opt_field">
<span>Tileset loader:</span>
<select name="tileSetLoaders" id="tileSetLoadersSel"></select>
</div>
<div class="tileset_info" id="tilesetSrcLabel"></div>
<div class="tileset_info" id="tilesetHomeLink"></div>
<div class="tileset_info" id="tilesetDescriptionLabel"></div>
</div>
</details>
<div class="select_container layer sticky_top sticky_left" id="tilesetSelectContainer">
<span id="setSymbolsVisBtn">đī¸</span>
<select name="tileData" id="tileDataSel">
<option value="">Symbols</option>
</select>
<button id="addTileTagBtn" title="add">+</button>
<button id="removeTileTagBtn" title="remove">-</button>
</div>
<div class="select_container layer sticky_top2 tileset_opt_field sticky_settings sticky_left" style="display: none" id="tileFrameSelContainer">
<select name="tileFrameData" id="tileFrameSel">
<!-- <option value="anim1">anim1rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr</option>-->
</select>
<button id="addTileFrameBtn" title="add">+</button>
<button id="removeTileFrameBtn" title="remove">-</button>
frames: <input id="tileFrameCount" value="1" type="number" min="1">
<div title="Object parameters" class="menu parameters" id="objectParametersEditor">
â
<div class="dropdown">
<div class="item nohover">Object parameters</div>
<div class="item">
coming soon...
<!-- <label for="toggleFlipX" class="">Flip tile on x</label>-->
<!-- <input type="checkbox" id="toggleFlipX" style="display: none"> -->
<!-- <label class="toggleFlipX"></label>-->
</div>
</div>
ī¸</div>
</div>
<div class="tileset-container">
<div class="tileset-container-selection"></div>
<canvas id="tilesetCanvas" />
<!-- <div id="tilesetGridContainer" class="tileset_grid_container"></div>-->
</div>
</div>
<div class="card_right-column" style="position:relative" id="canvas_drag_area">
<div class="canvas_wrapper" id="canvas_wrapper">
<canvas id="mapCanvas" width="${width}" height="${height}"></canvas>
<div class="canvas_resizer" resizerdir="y"><input value="1" type="number" min="1" resizerdir="y"><span>-y-</span></div>
<div class="canvas_resizer vertical" resizerdir="x"><input value="${mapTileWidth}" type="number" min="1" resizerdir="x"><span>-x-</span></div>
</div>
</div>
<div class="card_right-column layers">
<div id="mapSelectContainer" class="tilemaps_selector">
<select name="mapsData" id="mapsDataSel"></select>
<button id="addMapBtn" title="Add tilemap">+</button>
<button id="removeMapBtn" title="Remove tilemap">-</button>
<button id="duplicateMapBtn" title="Duplicate tilemap">đ</button>
<a class="button" href="#popup1">đī¸</a>
<div id="popup1" class="overlay">
<div class="popup">
<h4>TileMap settings</h4>
<a class="close" href="#">×</a>
<div class="content">
<span class="flex">Width: </span><input id="canvasWidthInp" value="1" type="number" min="1">
<span class="flex">Height: </span><input id="canvasHeightInp" value="1" type="number" min="1">
<br/><br/>
<span class="flex">Grid tile size: </span><input type="number" id="gridCropSize" name="crop" placeholder="32" min="1" max="128">
<span class="flex">Grid color: </span><input type="color" value="#ff0000" id="gridColorSel">
<span class="flex">Show grid above: </span> <input type="checkbox" id="showGrid">
<br/><br/>
<div class="tileset_opt_field">
<button id="renameMapBtn" title="Rename map">Rename</button>
<button id="clearCanvasBtn" title="Clear map">Clear</button>
</div>
</div>
</div>
</div>
</div>
<label class="sticky add_layer">
<label id="activeLayerLabel" class="menu">
Editing Layer
</label>
<button id="addLayerBtn" title="Add layer">+</button>
</label>
<div class="layers" id="layers">
</div>
</div>
</div>
`
}
const getEmptyLayer = (name="layer")=> ({tiles:{}, visible: true, name, animatedTiles: {}, opacity: 1});
let tilesetImage, canvas, tilesetContainer, tilesetSelection, cropSize,
confirmBtn, tilesetGridContainer,
layersElement, resizingCanvas, mapTileHeight, mapTileWidth, tileDataSel,tileFrameSel,
tilesetDataSel, mapsDataSel, objectParametersEditor;
let TILESET_ELEMENTS = [];
let IMAGES = [{src:''}];
let ZOOM = 1;
let SIZE_OF_CROP = 32;
let WIDTH = 0;
let HEIGHT = 0;
const TOOLS = {
BRUSH: 0,
ERASE: 1,
PAN: 2,
PICK: 3,
RAND: 4,
FILL: 5
}
let PREV_ACTIVE_TOOL = 0;
let ACTIVE_TOOL = 0;
let ACTIVE_MAP = "";
let DISPLAY_SYMBOLS = false;
let SHOW_GRID = false;
const getEmptyMap = (name="map", mapWidth =20, mapHeight=20, tileSize = 32, gridColor="#00FFFF") =>
({layers: [getEmptyLayer("bottom"), getEmptyLayer("middle"), getEmptyLayer("top")], name,
mapWidth, mapHeight, tileSize, width: mapWidth * SIZE_OF_CROP,height: mapHeight * SIZE_OF_CROP, gridColor });
const getEmptyTilesetTag = (name, code, tiles ={}) =>({name,code,tiles});
const getEmptyTileSet = ({
src,
name = "tileset",
gridWidth,
gridHeight,
tileData = {},
symbolStartIdx,
tileSize = SIZE_OF_CROP,
tags = {},
frames = {},
width,
height,
description = "n/a"
}) => {
return { src, name, gridWidth, gridHeight, tileCount: gridWidth * gridHeight, tileData, symbolStartIdx,tileSize, tags, frames, description, width, height}
}
const getSnappedPos = (pos) => (Math.round(pos / (SIZE_OF_CROP)) * (SIZE_OF_CROP));
let selection = [{}];
let currentLayer = 0;
let isMouseDown = false;
let maps = {};
let tileSets = {};
let apiTileSetLoaders = {};
let selectedTileSetLoader = {};
let apiTileMapExporters = {};
let apiTileMapImporters = {};
let editedEntity
const getContext = () => canvas.getContext('2d');
const setLayer = (newLayer) => {
currentLayer = Number(newLayer);
const oldActivedLayer = document.querySelector('.layer.active');
if (oldActivedLayer) {
oldActivedLayer.classList.remove('active');
}
document.querySelector(`.layer[tile-layer="${newLayer}"]`)?.classList.add('active');
document.getElementById("activeLayerLabel").innerHTML = `
Editing Layer: ${maps[ACTIVE_MAP].layers[newLayer]?.name}
<div class="dropdown left">
<div class="item nohover">Layer: ${maps[ACTIVE_MAP].layers[newLayer]?.name} </div>
<div class="item">
<div class="slider-wrapper">
<label for="layerOpacitySlider">Opacity</label>
<input type="range" min="0" max="1" value="1" id="layerOpacitySlider" step="0.01">
<output for="layerOpacitySlider" id="layerOpacitySliderValue">${maps[ACTIVE_MAP].layers[newLayer]?.opacity}</output>
</div>
</div>
</div>
`;
document.getElementById("layerOpacitySlider").value = maps[ACTIVE_MAP].layers[newLayer]?.opacity;
document.getElementById("layerOpacitySlider").addEventListener("change", e =>{
addToUndoStack();
document.getElementById("layerOpacitySliderValue").innerText = e.target.value;
maps[ACTIVE_MAP].layers[currentLayer].opacity = Number(e.target.value);
draw();
updateLayers();
})
}
const setLayerIsVisible = (layer, override = null) => {
const layerNumber = Number(layer);
maps[ACTIVE_MAP].layers[layerNumber].visible = override ?? !maps[ACTIVE_MAP].layers[layerNumber].visible;
document
.getElementById(`setLayerVisBtn-${layer}`)
.innerHTML = maps[ACTIVE_MAP].layers[layerNumber].visible ? "đī¸": "đ";
draw();
}
const trashLayer = (layer) => {
const layerNumber = Number(layer);
maps[ACTIVE_MAP].layers.splice(layerNumber, 1);
updateLayers();
setLayer(maps[ACTIVE_MAP].layers.length - 1);
draw();
}
const addLayer = () => {
const newLayerName = prompt("Enter layer name", `Layer${maps[ACTIVE_MAP].layers.length + 1}`);
if(newLayerName !== null) {
maps[ACTIVE_MAP].layers.push(getEmptyLayer(newLayerName));
updateLayers();
}
}
const updateLayers = () => {
layersElement.innerHTML = maps[ACTIVE_MAP].layers.map((layer, index)=>{
return `
<div class="layer">
<div id="selectLayerBtn-${index}" class="layer select_layer" tile-layer="${index}" title="${layer.name}">${layer.name} ${layer.opacity < 1 ? ` (${layer.opacity})` : ""}</div>
<span id="setLayerVisBtn-${index}" vis-layer="${index}"></span>
<div id="trashLayerBtn-${index}" trash-layer="${index}" ${maps[ACTIVE_MAP].layers.length > 1 ? "":`disabled="true"`}>đī¸</div>
</div>
`
}).reverse().join("\n")
maps[ACTIVE_MAP].layers.forEach((_,index)=>{
document.getElementById(`selectLayerBtn-${index}`).addEventListener("click",e=>{
setLayer(e.target.getAttribute("tile-layer"));
addToUndoStack();
})
document.getElementById(`setLayerVisBtn-${index}`).addEventListener("click",e=>{
setLayerIsVisible(e.target.getAttribute("vis-layer"))
addToUndoStack();
})
document.getElementById(`trashLayerBtn-${index}`).addEventListener("click",e=>{
trashLayer(e.target.getAttribute("trash-layer"))
addToUndoStack();
})
setLayerIsVisible(index, true);
})
setLayer(currentLayer);
}
const getTileData = (x= null,y= null) =>{
const tilesetTiles = tileSets[tilesetDataSel.value].tileData;
let data;
if(x === null && y === null){
const {x: sx, y: sy} = selection[0];
return tilesetTiles[`${sx}-${sy}`];
} else {
data = tilesetTiles[`${x}-${y}`]
}
return data;
}
const setTileData = (x = null,y = null,newData, key= "") =>{
const tilesetTiles = tileSets[tilesetDataSel.value].tileData;
if(x === null && y === null){
const {x:sx, y:sy} = selection[0];
tilesetTiles[`${sx}-${sy}`] = newData;
}
if(key !== ""){
tilesetTiles[`${x}-${y}`][key] = newData;
}else{
tilesetTiles[`${x}-${y}`] = newData;
}
}
const setActiveTool = (toolIdx) => {
ACTIVE_TOOL = toolIdx;
const actTool = document.getElementById("toolButtonsWrapper").querySelector(`input[id="tool${toolIdx}"]`);
if (actTool) actTool.checked = true;
document.getElementById("canvas_wrapper").setAttribute("isDraggable", ACTIVE_TOOL === TOOLS.PAN);
draw();
}
let selectionSize = [1,1];
const updateSelection = () => {
if(!tileSets[tilesetDataSel.value]) return;
const selected = selection[0];
if(!selected) return;
const {x, y} = selected;
const {x: endX, y: endY} = selection[selection.length - 1];
const selWidth = endX - x + 1;
const selHeight = endY - y + 1;
selectionSize = [selWidth, selHeight]
console.log(tileSets[tilesetDataSel.value].tileSize)
const tileSize = tileSets[tilesetDataSel.value].tileSize;
tilesetSelection.style.left = `${x * tileSize * ZOOM}px`;
tilesetSelection.style.top = `${y * tileSize * ZOOM}px`;
tilesetSelection.style.width = `${selWidth * tileSize * ZOOM}px`;
tilesetSelection.style.height = `${selHeight * tileSize * ZOOM}px`;
// Autoselect tool upon selecting a tile
if(![TOOLS.BRUSH, TOOLS.RAND, TOOLS.FILL].includes(ACTIVE_TOOL)) setActiveTool(TOOLS.BRUSH);
// show/hide param editor
if(tileDataSel.value === "frames" && editedEntity) objectParametersEditor.classList.add('entity');
else objectParametersEditor.classList.remove('entity');
}
const randomLetters = new Array(10680).fill(1).map((_, i) => String.fromCharCode(165 + i));
const shouldHideSymbols = () => SIZE_OF_CROP < 10 && ZOOM < 2;
const updateTilesetGridContainer = () =>{
const viewMode = tileDataSel.value;
const tilesetData = tileSets[tilesetDataSel.value];
if(!tilesetData) return;
const {tileCount, gridWidth, tileData, tags} = tilesetData;
console.log("COUNT", tileCount)
const hideSymbols = !DISPLAY_SYMBOLS || shouldHideSymbols();
const canvas = document.getElementById("tilesetCanvas");
const img = TILESET_ELEMENTS[tilesetDataSel.value];
canvas.width = img.width * ZOOM;
canvas.height = img.height * ZOOM;
const ctx = canvas.getContext('2d');
if (ZOOM !== 1){
ctx.webkitImageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
ctx.imageSmoothingEnabled = false;
}
ctx.drawImage(img,0,0,canvas.width ,canvas.height);
console.log("WIDTH EXCEEDS?", canvas.width % SIZE_OF_CROP)
const tileSizeSeemsIncorrect = canvas.width % SIZE_OF_CROP !== 0;
drawGrid(ctx.canvas.width, ctx.canvas.height, ctx,SIZE_OF_CROP * ZOOM, tileSizeSeemsIncorrect ? "red":"cyan");
Array.from({length: tileCount}, (x, i) => i).map(tile=>{
if (viewMode === "frames") {
const frameData = getCurrentFrames();
if(!frameData || Object.keys(frameData).length === 0) return;
const {width, height, start, tiles,frameCount} = frameData;
selection = [...tiles];
ctx.lineWidth = 0.5;
ctx.strokeStyle = "red";
ctx.strokeRect(SIZE_OF_CROP * ZOOM * (start.x + width), SIZE_OF_CROP * ZOOM * start.y, SIZE_OF_CROP * ZOOM * (width * (frameCount - 1)), SIZE_OF_CROP * ZOOM * height);
} else if (!hideSymbols) {
const x = tile % gridWidth;
const y = Math.floor(tile / gridWidth);
const tileKey = `${x}-${y}`;
const innerTile = viewMode === "" ?
tileData[tileKey]?.tileSymbol :
viewMode === "frames" ? tile :tags[viewMode]?.tiles[tileKey]?.mark || "-";
ctx.fillStyle = 'white';
ctx.font = '11px arial';
ctx.shadowColor="black";
ctx.shadowBlur=4;
ctx.lineWidth=2;
const posX = (x * SIZE_OF_CROP * ZOOM) + ((SIZE_OF_CROP * ZOOM) / 3);
const posY = (y * SIZE_OF_CROP * ZOOM) + ((SIZE_OF_CROP * ZOOM) / 2);
ctx.fillText(innerTile,posX,posY);
}
})
}
let tileSelectStart = null;
const getSelectedTile = (event) => {
const { x, y } = event.target.getBoundingClientRect();
const tileSize = tileSets[tilesetDataSel.value].tileSize * ZOOM;
const tx = Math.floor(Math.max(event.clientX - x, 0) / tileSize);
const ty = Math.floor(Math.max(event.clientY - y, 0) / tileSize);
// add start tile, add end tile, add all tiles inbetween
const newSelection = [];
if (tileSelectStart !== null){
for (let ix = tileSelectStart.x; ix < tx + 1; ix++) {
for (let iy = tileSelectStart.y; iy < ty + 1; iy++) {
const data = getTileData(ix,iy);
newSelection.push({...data, x:ix,y:iy})
}
}
}
if (newSelection.length > 0) return newSelection;
const data = getTileData(tx, ty);
return [{...data, x:tx,y:ty}];
}
const draw = (shouldDrawGrid = true) =>{
const ctx = getContext();
ctx.clearRect(0, 0, WIDTH, HEIGHT);
ctx.canvas.width = WIDTH;
ctx.canvas.height = HEIGHT;
if(shouldDrawGrid && !SHOW_GRID)drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM, maps[ACTIVE_MAP].gridColor);
const shouldHideHud = shouldHideSymbols();
maps[ACTIVE_MAP].layers.forEach((layer) => {
if(!layer.visible) return;
ctx.globalAlpha = layer.opacity;
if (ZOOM !== 1){
ctx.webkitImageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
ctx.imageSmoothingEnabled = false;
}
//static tiles on this layer
Object.keys(layer.tiles).forEach((key) => {
const [positionX, positionY] = key.split('-').map(Number);
const {x, y, tilesetIdx, isFlippedX} = layer.tiles[key];
const tileSize = tileSets[tilesetIdx]?.tileSize || SIZE_OF_CROP;
if(!(tilesetIdx in TILESET_ELEMENTS)) { //texture not found
ctx.fillStyle = 'red';
ctx.fillRect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM);
return;
}
if(isFlippedX){
ctx.save();//Special canvas crap to flip a slice, cause drawImage cant do it
ctx.translate(ctx.canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(
TILESET_ELEMENTS[tilesetIdx],
x * tileSize,
y * tileSize,
tileSize,
tileSize,
ctx.canvas.width - (positionX * SIZE_OF_CROP * ZOOM) - SIZE_OF_CROP * ZOOM,
positionY * SIZE_OF_CROP * ZOOM,
SIZE_OF_CROP * ZOOM,
SIZE_OF_CROP * ZOOM
);
ctx.restore();
} else {
ctx.drawImage(
TILESET_ELEMENTS[tilesetIdx],
x * tileSize,
y * tileSize,
tileSize,
tileSize,
positionX * SIZE_OF_CROP * ZOOM,
positionY * SIZE_OF_CROP * ZOOM,
SIZE_OF_CROP * ZOOM,
SIZE_OF_CROP * ZOOM
);
}
});
// animated tiles
Object.keys(layer.animatedTiles || {}).forEach((key) => {
const [positionX, positionY] = key.split('-').map(Number);
const {start, width, height, frameCount, isFlippedX} = layer.animatedTiles[key];
const {x, y, tilesetIdx} = start;
const tileSize = tileSets[tilesetIdx]?.tileSize || SIZE_OF_CROP;
if(!(tilesetIdx in TILESET_ELEMENTS)) { //texture not found
ctx.fillStyle = 'yellow';
ctx.fillRect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height);
ctx.fillStyle = 'blue';
ctx.fillText("X",positionX * SIZE_OF_CROP * ZOOM + 5,positionY * SIZE_OF_CROP * ZOOM + 10);
return;
}
const frameIndex = tileDataSel.value === "frames" || frameCount === 1 ? Math.round(Date.now()/120) % frameCount : 1; //30fps
if(isFlippedX) {
ctx.save();//Special canvas crap to flip a slice, cause drawImage cant do it
ctx.translate(ctx.canvas.width, 0);
ctx.scale(-1, 1);
const positionXFlipped = ctx.canvas.width - (positionX * SIZE_OF_CROP * ZOOM) - SIZE_OF_CROP * ZOOM;
if(shouldDrawGrid && !shouldHideHud) {
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(250,240,255, 0.7)';
ctx.rect(positionXFlipped, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height);
ctx.stroke();
}
ctx.drawImage(
TILESET_ELEMENTS[tilesetIdx],
x * tileSize + (frameIndex * tileSize * width),
y * tileSize,
tileSize * width,// src width
tileSize * height, // src height
positionXFlipped,
positionY * SIZE_OF_CROP * ZOOM, //target y
SIZE_OF_CROP * ZOOM * width, // target width
SIZE_OF_CROP * ZOOM * height // target height
);
if(shouldDrawGrid && !shouldHideHud) {
ctx.fillStyle = 'white';
ctx.fillText("đ",positionXFlipped + 5,positionY * SIZE_OF_CROP * ZOOM + 10);
}
ctx.restore();
}else {
if(shouldDrawGrid && !shouldHideHud) {
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(250,240,255, 0.7)';
ctx.rect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height);
ctx.stroke();
}
ctx.drawImage(
TILESET_ELEMENTS[tilesetIdx],
x * tileSize + (frameIndex * tileSize * width),//src x
y * tileSize,//src y
tileSize * width,// src width
tileSize * height, // src height
positionX * SIZE_OF_CROP * ZOOM, //target x
positionY * SIZE_OF_CROP * ZOOM, //target y
SIZE_OF_CROP * ZOOM * width, // target width
SIZE_OF_CROP * ZOOM * height // target height
);
if(shouldDrawGrid && !shouldHideHud) {
ctx.fillStyle = 'white';
ctx.fillText("â",positionX * SIZE_OF_CROP * ZOOM + 5,positionY * SIZE_OF_CROP * ZOOM + 10);
}
}
})
});
if(SHOW_GRID)drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM, maps[ACTIVE_MAP].gridColor);
}
const setMouseIsTrue=(e)=> {
if(e.button === 0) {
isMouseDown = true;
}
else if(e.button === 1){
PREV_ACTIVE_TOOL = ACTIVE_TOOL;
setActiveTool(TOOLS.PAN)
}
}
const setMouseIsFalse=(e)=> {
if(e.button === 0) {
isMouseDown = false;
}
else if(e.button === 1 && ACTIVE_TOOL === TOOLS.PAN){
setActiveTool(PREV_ACTIVE_TOOL)
}
}
const removeTile=(key) =>{
delete maps[ACTIVE_MAP].layers[currentLayer].tiles[key];
if (key in (maps[ACTIVE_MAP].layers[currentLayer].animatedTiles || {})) delete maps[ACTIVE_MAP].layers[currentLayer].animatedTiles[key];
}
const isFlippedOnX = () => document.getElementById("toggleFlipX").checked;
const addSelectedTiles = (key, tiles) => {
const [x, y] = key.split("-");
const tilesPatch = tiles || selection; // tiles is opt override for selection for fancy things like random patch of tiles
const {x: startX, y: startY} = tilesPatch[0];// add selection override
const selWidth = selectionSize[0];
const selHeight = selectionSize[1];
maps[ACTIVE_MAP].layers[currentLayer].tiles[key] = tilesPatch[0];
const isFlippedX = isFlippedOnX();
for (let ix = 0; ix < selWidth; ix++) {
for (let iy = 0; iy < selHeight; iy++) {
const tileX = isFlippedX ? Number(x)-ix : Number(x)+ix;//placed in reverse when flipped on x
const coordKey = `${tileX}-${Number(y)+iy}`;
maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = {
...tilesPatch
.find(tile => tile.x === startX + ix && tile.y === startY + iy),
isFlippedX
};
}
}
}
const getCurrentFrames = () => tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value];
const getSelectedFrameCount = () => getCurrentFrames()?.frameCount || 1;
const shouldNotAddAnimatedTile = () => (tileDataSel.value !== "frames" && getSelectedFrameCount() !== 1) || Object.keys(tileSets[tilesetDataSel.value]?.frames).length === 0;
const addTile = (key) => {
if (shouldNotAddAnimatedTile()) {
addSelectedTiles(key);
} else {
// if animated tile mode and has more than one frames, add/remove to animatedTiles
if(!maps[ACTIVE_MAP].layers[currentLayer].animatedTiles) maps[ACTIVE_MAP].layers[currentLayer].animatedTiles = {};
const isFlippedX = isFlippedOnX();
const [x,y] = key.split("-");
maps[ACTIVE_MAP].layers[currentLayer].animatedTiles[key] = {
...getCurrentFrames(),
isFlippedX, layer: currentLayer,
xPos: Number(x) * SIZE_OF_CROP, yPos: Number(y) * SIZE_OF_CROP
};
}
}
const addRandomTile = (key) =>{
// TODO add probability for empty
if (shouldNotAddAnimatedTile()) {
maps[ACTIVE_MAP].layers[currentLayer].tiles[key] = selection[Math.floor(Math.random()*selection.length)];
}else {
// do the same, but add random from frames instead
const tilesetTiles = tileSets[tilesetDataSel.value].tileData;
const {frameCount, tiles, width} = getCurrentFrames();
const randOffset = Math.floor(Math.random()*frameCount);
const randXOffsetTiles = tiles.map(tile=>tilesetTiles[`${tile.x + randOffset * width}-${tile.y}`]);
addSelectedTiles(key,randXOffsetTiles);
}
}
const fillEmptyOrSameTiles = (key) => {
const pickedTile = maps[ACTIVE_MAP].layers[currentLayer].tiles[key];
Array.from({length: mapTileWidth * mapTileHeight}, (x, i) => i).map(tile=>{
const x = tile % mapTileWidth;
const y = Math.floor(tile / mapTileWidth);
const coordKey = `${x}-${y}`;
const filledTile = maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey];
if(pickedTile && filledTile && filledTile.x === pickedTile.x && filledTile.y === pickedTile.y){
maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = selection[0];// Replace all clicked on tiles with selected
}
else if(!pickedTile && !(coordKey in maps[ACTIVE_MAP].layers[currentLayer].tiles)) {
maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = selection[0]; // when clicked on empty, replace all empty with selection
}
})
}
const selectMode = (mode = null) => {
if (mode !== null) tileDataSel.value = mode;
document.getElementById("tileFrameSelContainer").style.display = tileDataSel.value === "frames" ?
"flex":"none"
// tilesetContainer.style.top = tileDataSel.value === "frames" ? "45px" : "0";
updateTilesetGridContainer();
}
const getTile =(key, allLayers = false)=> {
const layers = maps[ACTIVE_MAP].layers;
editedEntity = undefined;
const clicked = allLayers ?
[...layers].reverse().find((layer,index)=> {
if(layer.animatedTiles && key in layer.animatedTiles) {
setLayer(layers.length - index - 1);
editedEntity = layer.animatedTiles[key];
}
if(key in layer.tiles){
setLayer(layers.length - index - 1);
return layer.tiles[key]
}
})?.tiles[key] //TODO this doesnt work on animatedTiles
:
layers[currentLayer].tiles[key];
if (clicked && !editedEntity) {
selection = [clicked];
console.log("clicked", clicked, "entity data",editedEntity)
document.getElementById("toggleFlipX").checked = !!clicked?.isFlippedX;
// TODO switch to different tileset if its from a different one
// if(clicked.tilesetIdx !== tilesetDataSel.value) {
// tilesetDataSel.value = clicked.tilesetIdx;
// reloadTilesets();
// updateTilesetGridContainer();
// }
selectMode("");
updateSelection();
return true;
} else if (editedEntity){
console.log("Animated tile found", editedEntity)
selection = editedEntity.tiles;
document.getElementById("toggleFlipX").checked = editedEntity.isFlippedX;
setLayer(editedEntity.layer);
tileFrameSel.value = editedEntity.name;
updateSelection();
selectMode("frames");
return true;
}else {
return false;
}
}
const toggleTile=(event)=> {
if(ACTIVE_TOOL === TOOLS.PAN || !maps[ACTIVE_MAP].layers[currentLayer].visible) return;
const {x,y} = getSelectedTile(event)[0];
const key = `${x}-${y}`;
console.log(event.button)
if (event.shiftKey) {
removeTile(key);
} else if (event.ctrlKey || event.button === 2 || ACTIVE_TOOL === TOOLS.PICK) {
const pickedTile = getTile(key, true);
if(ACTIVE_TOOL === TOOLS.BRUSH && !pickedTile) setActiveTool(TOOLS.ERASE); //picking empty tile, sets tool to eraser
else if(ACTIVE_TOOL === TOOLS.FILL || ACTIVE_TOOL === TOOLS.RAND) setActiveTool(TOOLS.BRUSH); //
} else {
if(ACTIVE_TOOL === TOOLS.BRUSH){
addTile(key);// also works with animated
} else if(ACTIVE_TOOL === TOOLS.ERASE) {
removeTile(key);// also works with animated
} else if (ACTIVE_TOOL === TOOLS.RAND){
addRandomTile(key);
} else if (ACTIVE_TOOL === TOOLS.FILL){
fillEmptyOrSameTiles(key);
}
}
draw();
addToUndoStack();
}
const clearCanvas = () => {
addToUndoStack();
maps[ACTIVE_MAP].layers = [getEmptyLayer("bottom"), getEmptyLayer("middle"), getEmptyLayer("top")];
setLayer(0);
updateLayers();
draw();
addToUndoStack();
}
const downloadAsTextFile = (input, fileName = "tilemap-editor.json") =>{
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(typeof input === "string" ? input : JSON.stringify(input));
const dlAnchorElem = document.getElementById('downloadAnchorElem');
dlAnchorElem.setAttribute("href", dataStr );
dlAnchorElem.setAttribute("download", fileName);
dlAnchorElem.click();
}
const exportJson = () => {
downloadAsTextFile({tileSets, maps});
}
const exportImage = () => {
draw(false);
const data = canvas.toDataURL();
const image = new Image();
image.src = data;
image.crossOrigin = "anonymous";
const w = window.open('');
w.document.write(image.outerHTML);
draw();
}
const getTilesAnalisis = (ctx, width, height, sizeOfTile) =>{
const analizedTiles = {};
let uuid = 0;
for (let y = 0; y < height; y += sizeOfTile) {
for (let x = 0; x < width; x += sizeOfTile) {
console.log(x, y);
const tileData = ctx.getImageData(x, y, sizeOfTile, sizeOfTile);
const index = tileData.data.toString();
if (analizedTiles[index]) {
analizedTiles[index].coords.push({ x: x, y: y });
analizedTiles[index].times++;
} else {
analizedTiles[index] = {
uuid: uuid++,
coords: [{ x: x, y: y }],
times: 1,
tileData: tileData
};
}
}
}
const uniqueTiles = Object.values(analizedTiles).length - 1;
console.log("TILES:", {analizedTiles, uniqueTiles})
return {analizedTiles, uniqueTiles};
}
const drawAnaliticsReport = () => {
const prevZoom = ZOOM;
ZOOM = 1;// needed for correct eval
updateZoom();
draw(false);
const {analizedTiles, uniqueTiles} = getTilesAnalisis(getContext(), WIDTH, HEIGHT, SIZE_OF_CROP);
const data = canvas.toDataURL();
const image = new Image();
image.src = data;
const ctx = getContext();
ZOOM = prevZoom;
updateZoom();
draw(false);
Object.values(analizedTiles).map((t) => {
// Fill the heatmap
t.coords.forEach((c, i) => {
const fillStyle = `rgba(255, 0, 0, ${(1/t.times) - 0.35})`;
ctx.fillStyle = fillStyle;
ctx.fillRect(c.x * ZOOM, c.y * ZOOM, SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM);
});
})
drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM,'rgba(255,213,0,0.5)')
ctx.fillStyle = 'white';
ctx.font = 'bold 17px arial';
ctx.shadowColor="black";
ctx.shadowBlur=5;
ctx.lineWidth=3;
ctx.fillText(`Unique tiles: ${uniqueTiles}`,4,HEIGHT - 30);
ctx.fillText(`Map size: ${mapTileWidth}x${mapTileHeight}`,4,HEIGHT - 10);
}
const exportUniqueTiles = () => {
const ctx = getContext();
const prevZoom = ZOOM;
ZOOM = 1;// needed for correct eval
updateZoom();
draw(false);
const {analizedTiles} = getTilesAnalisis(getContext(), WIDTH, HEIGHT, SIZE_OF_CROP);
ctx.clearRect(0, 0, WIDTH, HEIGHT);
const gridWidth = tilesetImage.width / SIZE_OF_CROP;
Object.values(analizedTiles).map((t, i) => {
const positionX = i % gridWidth;
const positionY = Math.floor(i / gridWidth);
const tileCanvas = document.createElement("canvas");
tileCanvas.width = SIZE_OF_CROP;
tileCanvas.height = SIZE_OF_CROP;
const tileCtx = tileCanvas.getContext("2d");
tileCtx.putImageData(t.tileData, 0, 0);
ctx.drawImage(
tileCanvas,
0,
0,
SIZE_OF_CROP,
SIZE_OF_CROP,
positionX * SIZE_OF_CROP,
positionY * SIZE_OF_CROP,
SIZE_OF_CROP,
SIZE_OF_CROP
);
});
const data = canvas.toDataURL();
const image = new Image();
image.src = data;
image.crossOrigin = "anonymous";
const w = window.open('');
w.document.write(image.outerHTML);
ZOOM = prevZoom;
updateZoom();
draw();
}
exports.getLayers = ()=> {
return maps[ACTIVE_MAP].layers;
}
const renameCurrentTileSymbol = ()=>{
const {x, y, tileSymbol} = selection[0];
const newSymbol = window.prompt("Enter tile symbol", tileSymbol || "*");
if(newSymbol !== null) {
setTileData(x,y,newSymbol, "tileSymbol");
updateSelection();
updateTilesetGridContainer();
addToUndoStack();
}
}
const getFlattenedData = () => {
const result = Object.entries(maps).map(([key, map])=>{
const layers = map.layers;
const flattenedData = Array(layers.length).fill([]).map(()=>{
return Array(map.mapHeight).fill([]).map(row=>{
return Array(map.mapWidth).fill([]).map(column => ({
tile: null,
tileSymbol: " "// a space is an empty tile
}))
})
});
layers.forEach((layerObj,lrIndex) => {
Object.entries(layerObj.tiles).forEach(([key,tile])=>{
const [x,y] = key.split("-");
if(Number(y) < map.mapHeight && Number(x) < map.mapWidth) {
flattenedData[lrIndex][Number(y)][Number(x)] = {tile, tileSymbol: tile.tileSymbol || "*"};
}
})
});
return {map:key,flattenedData};
});
return result;
};
const getExportData = () => {
const exportData = {maps, tileSets, flattenedData: getFlattenedData(), activeMap: ACTIVE_MAP, downloadAsTextFile};
console.log("Exported ", exportData);
return exportData;
}
const updateMapSize = (size) =>{
if(size?.mapWidth && size?.mapWidth > 1){
mapTileWidth = size?.mapWidth;
WIDTH = mapTileWidth * SIZE_OF_CROP * ZOOM;
maps[ACTIVE_MAP].mapWidth = mapTileWidth;
document.querySelector(".canvas_resizer[resizerdir='x']").style=`left:${WIDTH}px`;
document.querySelector(".canvas_resizer[resizerdir='x'] input").value = String(mapTileWidth);
document.getElementById("canvasWidthInp").value = String(mapTileWidth);
}
if(size?.mapHeight && size?.mapHeight > 1){
mapTileHeight = size?.mapHeight;
HEIGHT = mapTileHeight * SIZE_OF_CROP * ZOOM;
maps[ACTIVE_MAP].mapHeight = mapTileHeight;
document.querySelector(".canvas_resizer[resizerdir='y']").style=`top:${HEIGHT}px`;
document.querySelector(".canvas_resizer[resizerdir='y'] input").value = String(mapTileHeight);
document.getElementById("canvasHeightInp").value = String(mapTileHeight);
}
draw();
}
const setActiveMap =(id) =>{
ACTIVE_MAP = id;
document.getElementById("gridColorSel").value = maps[ACTIVE_MAP].gridColor || "#00FFFF";
draw();
updateMapSize({mapWidth: maps[ACTIVE_MAP].mapWidth, mapHeight: maps[ACTIVE_MAP].mapHeight})
updateLayers();
}
let undoStepPosition = -1;
let undoStack = [];
const clearUndoStack = () => {
undoStack = [];
undoStepPosition = -1;
}
const addToUndoStack = () => {
if(Object.keys(tileSets).length === 0 || Object.keys(maps).length === 0) return;
const oldState = undoStack.length > 0 ? JSON.stringify(
{
maps: undoStack[undoStepPosition].maps,
tileSets: undoStack[undoStepPosition].tileSets,
currentLayer:undoStack[undoStepPosition].currentLayer,
ACTIVE_MAP:undoStack[undoStepPosition].ACTIVE_MAP,
IMAGES:undoStack[undoStepPosition].IMAGES
}) : undefined;
const newState = JSON.stringify({maps,tileSets,currentLayer,ACTIVE_MAP,IMAGES});
if (newState === oldState) return; // prevent updating when no changes are present in the data!
undoStepPosition += 1;
undoStack.length = undoStepPosition;
undoStack.push(JSON.parse(JSON.stringify({maps,tileSets, currentLayer, ACTIVE_MAP, IMAGES, undoStepPosition})));
// console.log("undo stack updated", undoStack, undoStepPosition)
}
const restoreFromUndoStackData = () => {
maps = decoupleReferenceFromObj(undoStack[undoStepPosition].maps);
const undoTileSets = decoupleReferenceFromObj(undoStack[undoStepPosition].tileSets);
const undoIMAGES = decoupleReferenceFromObj(undoStack[undoStepPosition].IMAGES);
if(JSON.stringify(IMAGES) !== JSON.stringify(undoIMAGES)){ // images needs to happen before tilesets
IMAGES = undoIMAGES;
reloadTilesets();
}
if(JSON.stringify(undoTileSets) !== JSON.stringify(tileSets)) { // done to prevent the below, which is expensive
tileSets = undoTileSets;
updateTilesetGridContainer();
}
tileSets = undoTileSets;
updateTilesetDataList();
const undoLayer = decoupleReferenceFromObj(undoStack[undoStepPosition].currentLayer);
const undoActiveMap = decoupleReferenceFromObj(undoStack[undoStepPosition].ACTIVE_MAP);
if(undoActiveMap !== ACTIVE_MAP){
setActiveMap(undoActiveMap)
updateMaps();
}
updateLayers(); // needs to happen after active map is set and maps are updated
setLayer(undoLayer);
draw();
}
const undo = () => {
if (undoStepPosition === 0) return;
undoStepPosition -= 1;
restoreFromUndoStackData();
}
const redo = () => {
if (undoStepPosition === undoStack.length - 1) return;
undoStepPosition += 1;
restoreFromUndoStackData();
}
const zoomLevels = [0.25, 0.5, 1,