UNPKG

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
// @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="#">&times;</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="#">&times;</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,