1<!doctype html>
  2<html lang="en">
  3  <head>
  4    <meta charset="utf-8" />
  5    <meta name="viewport" content="width=device-width, initial-scale=1" />
  6    <title>ASCII Map Editor</title>
  7    <style>
  8      :root {
  9        --gridline: rgba(0, 0, 0, 0.15);
 10        --mono: "SFMono-Regular", Menlo, Consolas, monospace;
 11      }
 12
 13      body {
 14        margin: 0;
 15        font-family: system-ui, sans-serif;
 16        height: 100vh;
 17        overflow: hidden;
 18        display: flex;
 19        flex-direction: column;
 20      }
 21
 22      header {
 23        padding: 12px 16px 8px;
 24      }
 25
 26      header h1 {
 27        margin: 0 0 4px;
 28        font-size: 1rem;
 29      }
 30
 31      header p {
 32        margin: 0;
 33      }
 34
 35      .layout {
 36        display: grid;
 37        grid-template-columns: 280px 1fr;
 38        gap: 12px;
 39        padding: 0 16px 16px;
 40        flex: 1;
 41        min-height: 0;
 42      }
 43
 44      .panel,
 45      .grid-wrap {
 46        border: 1px solid #ccc;
 47        padding: 8px;
 48        overflow: auto;
 49        min-height: 0;
 50      }
 51
 52      .panel h2 {
 53        font-size: 0.9rem;
 54        margin: 0 0 8px;
 55      }
 56
 57      .controls {
 58        display: grid;
 59        gap: 10px;
 60      }
 61
 62      .control-group {
 63        display: grid;
 64        gap: 8px;
 65      }
 66
 67      .row {
 68        display: flex;
 69        align-items: center;
 70        gap: 8px;
 71      }
 72
 73      .palette {
 74        display: grid;
 75        grid-template-columns: repeat(4, 1fr);
 76        gap: 4px;
 77      }
 78
 79      .tile-btn {
 80        padding: 4px 0;
 81        font-family: var(--mono);
 82      }
 83
 84      .button {
 85        padding: 4px 8px;
 86      }
 87
 88      input[type="range"] {
 89        width: 100%;
 90      }
 91
 92      .stat {
 93        font-family: var(--mono);
 94      }
 95
 96      #gridCanvas {
 97        display: block;
 98        image-rendering: pixelated;
 99        cursor: crosshair;
100      }
101
102      .footer-note {
103        font-size: 0.85rem;
104      }
105
106      @media (max-width: 980px) {
107        .layout {
108          grid-template-columns: 1fr;
109        }
110      }
111    </style>
112  </head>
113  <body>
114    <header></header>
115    <div class="layout">
116      <aside class="panel">
117        <h2>Tools</h2>
118        <div class="controls">
119          <div class="control-group">
120            <div class="row">
121              <span>Tile</span>
122              <span class="stat" id="activeTile">#</span>
123            </div>
124            <div class="palette" id="palette"></div>
125          </div>
126          <div class="control-group">
127            <div class="row">
128              <span>Brush</span>
129              <span class="stat" id="brushLabel">1</span>
130            </div>
131            <input type="range" id="brushSize" min="1" max="5" value="1" />
132          </div>
133          <div class="control-group">
134            <div class="row">
135        <span>Grid</span>
136              <span class="stat" id="gridSize">0 x 0</span>
137            </div>
138            <div class="row">
139              <button class="button secondary" id="toggleGrid">Toggle Gridlines</button>
140              <button class="button secondary" id="clearMap">Clear</button>
141            </div>
142            <div class="row">
143              <button class="button secondary" id="addBorder">Add Border</button>
144              <button class="button secondary" id="addDoubleBorder">Add Double Border</button>
145            </div>
146            <div class="row">
147              <label>
148                W
149                <input type="number" id="gridWidth" min="1" value="118" style="width: 64px" />
150              </label>
151              <label>
152                H
153                <input type="number" id="gridHeight" min="1" value="59" style="width: 64px" />
154              </label>
155              <button class="button secondary" id="resizeGrid">Resize</button>
156            </div>
157          </div>
158          <div class="control-group">
159            <div class="row">
160              <button class="button" id="saveMap">Download .txt</button>
161              <button class="button secondary" id="copyMap">Copy</button>
162            </div>
163            <div class="row">
164              <input type="file" id="fileInput" accept=".txt" />
165            </div>
166            <div class="footer-note" id="status">Loaded maps/map1.txt</div>
167          </div>
168        </div>
169      </aside>
170      <main class="grid-wrap" id="gridWrap">
171        <canvas id="gridCanvas" aria-label="Map grid" role="img"></canvas>
172      </main>
173    </div>
174
175    <script>
176      const storageKey = "mapeditor-state";
177      const defaultCols = 118;
178      const defaultRows = 59;
179      const tileOrder = [
180        "┌",
181        "┐",
182        "└",
183        "┘",
184        "─",
185        "│",
186        "╔",
187        "╗",
188        "╚",
189        "╝",
190        "═",
191        "║",
192        "0",
193        "1",
194        "2",
195        "3",
196        "4",
197        "5",
198        "6",
199        "7",
200        "8",
201        "9",
202        "#",
203        ".",
204        "~",
205        " ",
206        "N",
207        "B",
208        "S",
209        "G",
210        "$",
211      ];
212      const tileLabels = {
213        "#": "Wall",
214        ".": "Floor",
215        "~": "Water",
216        "N": "Cyan",
217        "B": "Red",
218        "S": "Red",
219        "G": "Red",
220        "$": "Gold",
221        "0": "Cyan",
222        "1": "Cyan",
223        "2": "Cyan",
224        "3": "Cyan",
225        "4": "Cyan",
226        "5": "Cyan",
227        "6": "Cyan",
228        "7": "Cyan",
229        "8": "Cyan",
230        "9": "Cyan",
231        " ": "Void",
232        "┌": "Border corner",
233        "┐": "Border corner",
234        "└": "Border corner",
235        "┘": "Border corner",
236        "─": "Border",
237        "│": "Border",
238        "╔": "Double border corner",
239        "╗": "Double border corner",
240        "╚": "Double border corner",
241        "╝": "Double border corner",
242        "═": "Double border",
243        "║": "Double border",
244      };
245
246      const canvas = document.getElementById("gridCanvas");
247      const ctx = canvas.getContext("2d");
248      const gridWrap = document.getElementById("gridWrap");
249      const paletteEl = document.getElementById("palette");
250      const activeTileEl = document.getElementById("activeTile");
251      const brushSizeEl = document.getElementById("brushSize");
252      const brushLabelEl = document.getElementById("brushLabel");
253      const gridSizeEl = document.getElementById("gridSize");
254      const toggleGridBtn = document.getElementById("toggleGrid");
255      const clearBtn = document.getElementById("clearMap");
256      const addBorderBtn = document.getElementById("addBorder");
257      const addDoubleBorderBtn = document.getElementById("addDoubleBorder");
258      const gridWidthEl = document.getElementById("gridWidth");
259      const gridHeightEl = document.getElementById("gridHeight");
260      const resizeGridBtn = document.getElementById("resizeGrid");
261      const saveBtn = document.getElementById("saveMap");
262      const copyBtn = document.getElementById("copyMap");
263      const fileInput = document.getElementById("fileInput");
264      const statusEl = document.getElementById("status");
265
266      let mapData = [];
267      let activeTile = "#";
268      let isPainting = false;
269      let lastCell = null;
270      let showGridLines = true;
271
272      const baseFontSize = 14;
273      const minZoom = 0.5;
274      const maxZoom = 4;
275      let zoom = 1;
276      let cellMetrics = { width: baseFontSize, height: baseFontSize, fontSize: baseFontSize };
277      let devicePixelRatioValue = window.devicePixelRatio || 1;
278      let autoSaveId = null;
279      const tileStyle = {
280        wall: { fill: "#8a6b3e", text: "#251a0b" },
281        floor: { fill: "#f7f7f7", text: "#b9b9b9" },
282        water: { fill: "#4a8fa3", text: "#e9f7f9" },
283        cyan: { fill: "#27c8d1", text: "#07353a" },
284        red: { fill: "#d84545", text: "#3d0a0a" },
285        gold: { fill: "#d7a627", text: "#3a2605" },
286        void: { fill: "#222", text: "#777" },
287      };
288
289      const tileClass = (tile) => {
290        if ("#┌┐└┘─│╔╗╚╝═║".includes(tile)) return "wall";
291        if (tile === ".") return "floor";
292        if (tile === "~") return "water";
293        if ("0123456789".includes(tile)) return "cyan";
294        if (tile === "N") return "cyan";
295        if ("BSG".includes(tile)) return "red";
296        if (tile === "$") return "gold";
297        return "void";
298      };
299
300      const setStatus = (text) => {
301        statusEl.textContent = text;
302      };
303
304      const normalizeMap = (lines) => {
305        const cleanLines = lines.filter((line) => line.length > 0);
306        const width = Math.max(...cleanLines.map((line) => line.length));
307        return cleanLines.map((line) => line.padEnd(width, " ").split(""));
308      };
309
310      const mapToText = () => mapData.map((row) => row.join("")).join("\n");
311
312      const buildCanvas = () => {
313        if (!mapData.length) return;
314        const rows = mapData.length;
315        const cols = mapData[0].length;
316        resizeCanvas(cols, rows);
317        gridSizeEl.textContent = `${cols} x ${rows}`;
318        gridWidthEl.value = cols;
319        gridHeightEl.value = rows;
320        renderAll();
321      };
322
323      const resizeCanvas = (cols, rows) => {
324        const fontSize = Math.max(8, Math.round(baseFontSize * zoom));
325        devicePixelRatioValue = window.devicePixelRatio || 1;
326        ctx.setTransform(devicePixelRatioValue, 0, 0, devicePixelRatioValue, 0, 0);
327        ctx.font = `${fontSize}px ${getComputedStyle(document.documentElement).getPropertyValue("--mono")}`;
328        const metrics = ctx.measureText("M");
329        const glyphWidth = Math.max(1, Math.ceil(metrics.width));
330        const glyphHeight = Math.max(
331          1,
332          Math.ceil((metrics.actualBoundingBoxAscent || fontSize) + (metrics.actualBoundingBoxDescent || 0))
333        );
334        cellMetrics = { width: glyphWidth, height: glyphHeight, fontSize };
335        canvas.style.width = `${cols * cellMetrics.width}px`;
336        canvas.style.height = `${rows * cellMetrics.height}px`;
337        canvas.width = Math.round(cols * cellMetrics.width * devicePixelRatioValue);
338        canvas.height = Math.round(rows * cellMetrics.height * devicePixelRatioValue);
339        ctx.setTransform(devicePixelRatioValue, 0, 0, devicePixelRatioValue, 0, 0);
340        ctx.font = `${fontSize}px ${getComputedStyle(document.documentElement).getPropertyValue("--mono")}`;
341        ctx.textAlign = "left";
342        ctx.textBaseline = "top";
343      };
344
345      const drawCell = (x, y, tile) => {
346        const type = tileClass(tile);
347        const style = tileStyle[type];
348        const px = x * cellMetrics.width;
349        const py = y * cellMetrics.height;
350        ctx.fillStyle = style.fill;
351        ctx.fillRect(px, py, cellMetrics.width, cellMetrics.height);
352        if (showGridLines) {
353          ctx.strokeStyle = getComputedStyle(document.documentElement)
354            .getPropertyValue("--gridline")
355            .trim();
356          ctx.strokeRect(
357            px + 0.5,
358            py + 0.5,
359            Math.max(1, cellMetrics.width - 1),
360            Math.max(1, cellMetrics.height - 1)
361          );
362        }
363        if (tile !== " ") {
364          ctx.fillStyle = style.text;
365          ctx.fillText(tile, px, py);
366        }
367      };
368
369      const renderAll = () => {
370        const rows = mapData.length;
371        const cols = mapData[0].length;
372        for (let y = 0; y < rows; y += 1) {
373          for (let x = 0; x < cols; x += 1) {
374            drawCell(x, y, mapData[y][x]);
375          }
376        }
377      };
378
379      const updateCell = (x, y, tile) => {
380        if (!mapData[y] || mapData[y][x] === undefined) return;
381        mapData[y][x] = tile;
382        drawCell(x, y, tile);
383      };
384
385      const paintAt = (x, y) => {
386        const size = Number(brushSizeEl.value);
387        const half = Math.floor(size / 2);
388        for (let dy = -half; dy <= half; dy += 1) {
389          for (let dx = -half; dx <= half; dx += 1) {
390            updateCell(x + dx, y + dy, activeTile);
391          }
392        }
393      };
394
395      const drawLine = (from, to) => {
396        const dx = Math.abs(to.x - from.x);
397        const dy = Math.abs(to.y - from.y);
398        const sx = from.x < to.x ? 1 : -1;
399        const sy = from.y < to.y ? 1 : -1;
400        let err = dx - dy;
401        let x = from.x;
402        let y = from.y;
403        while (true) {
404          paintAt(x, y);
405          if (x === to.x && y === to.y) break;
406          const e2 = err * 2;
407          if (e2 > -dy) {
408            err -= dy;
409            x += sx;
410          }
411          if (e2 < dx) {
412            err += dx;
413            y += sy;
414          }
415        }
416      };
417
418      const setActiveTile = (tile) => {
419        activeTile = tile;
420        activeTileEl.textContent = tile === " " ? "(space)" : tile;
421        paletteEl.querySelectorAll(".tile-btn").forEach((btn) => {
422          btn.classList.toggle("active", btn.dataset.tile === tile);
423        });
424      };
425
426      const setupPalette = () => {
427        paletteEl.innerHTML = "";
428        tileOrder.forEach((tile) => {
429          const btn = document.createElement("button");
430          btn.type = "button";
431          btn.className = "tile-btn";
432          btn.dataset.tile = tile;
433          btn.textContent = tile === " " ? "space" : tile;
434          btn.title = tileLabels[tile] || tile;
435          btn.addEventListener("click", () => setActiveTile(tile));
436          paletteEl.appendChild(btn);
437        });
438        setActiveTile(activeTile);
439      };
440
441      const loadFromText = (text, sourceLabel) => {
442        const lines = text.replace(/\r/g, "").split("\n");
443        mapData = normalizeMap(lines);
444        buildCanvas();
445        setStatus(`Loaded ${sourceLabel}`);
446      };
447
448      const loadDefaultMap = () => {
449        const row = ".".repeat(defaultCols);
450        const empty = Array.from({ length: defaultRows }, () => row).join("\n");
451        loadFromText(empty, "empty map");
452      };
453
454      const saveState = () => {
455        if (!mapData.length) return;
456        const state = {
457          map: mapToText(),
458          cols: mapData[0].length,
459          rows: mapData.length,
460          zoom,
461          brush: Number(brushSizeEl.value),
462          tile: activeTile,
463          grid: showGridLines,
464        };
465        try {
466          localStorage.setItem(storageKey, JSON.stringify(state));
467          setStatus("Autosaved");
468        } catch (error) {
469          setStatus("Autosave failed");
470        }
471      };
472
473      const loadState = () => {
474        try {
475          const raw = localStorage.getItem(storageKey);
476          if (!raw) return false;
477          const state = JSON.parse(raw);
478          if (!state?.map) return false;
479          loadFromText(state.map, "local autosave");
480          if (state.zoom) applyZoom(state.zoom);
481          if (state.brush) {
482            brushSizeEl.value = state.brush;
483            brushLabelEl.textContent = state.brush;
484          }
485          if (state.tile) setActiveTile(state.tile);
486          if (typeof state.grid === "boolean") showGridLines = state.grid;
487          renderAll();
488          return true;
489        } catch (error) {
490          return false;
491        }
492      };
493
494      brushSizeEl.addEventListener("input", () => {
495        brushLabelEl.textContent = brushSizeEl.value;
496      });
497
498      toggleGridBtn.addEventListener("click", () => {
499        showGridLines = !showGridLines;
500        renderAll();
501      });
502
503      clearBtn.addEventListener("click", () => {
504        mapData = mapData.map((row) => row.map(() => "."));
505        buildCanvas();
506        setStatus("Cleared to floor tiles");
507      });
508
509      resizeGridBtn.addEventListener("click", () => {
510        const newCols = Math.max(1, Number(gridWidthEl.value) || 1);
511        const newRows = Math.max(1, Number(gridHeightEl.value) || 1);
512        const oldRows = mapData.length;
513        const oldCols = mapData[0]?.length || 0;
514        const resized = [];
515        for (let y = 0; y < newRows; y += 1) {
516          const row = [];
517          for (let x = 0; x < newCols; x += 1) {
518            const tile = mapData[y]?.[x] ?? ".";
519            row.push(tile);
520          }
521          resized.push(row);
522        }
523        mapData = resized;
524        buildCanvas();
525        setStatus(`Resized from ${oldCols}x${oldRows} to ${newCols}x${newRows}`);
526      });
527
528      addBorderBtn.addEventListener("click", () => {
529        if (!mapData.length) return;
530        const rows = mapData.length;
531        const cols = mapData[0].length;
532        if (cols < 2 || rows < 2) return;
533        updateCell(0, 0, "┌");
534        updateCell(cols - 1, 0, "┐");
535        updateCell(0, rows - 1, "└");
536        updateCell(cols - 1, rows - 1, "┘");
537        for (let x = 1; x < cols - 1; x += 1) {
538          updateCell(x, 0, "─");
539          updateCell(x, rows - 1, "─");
540        }
541        for (let y = 1; y < rows - 1; y += 1) {
542          updateCell(0, y, "│");
543          updateCell(cols - 1, y, "│");
544        }
545        setStatus("Added box border tiles");
546      });
547
548      addDoubleBorderBtn.addEventListener("click", () => {
549        if (!mapData.length) return;
550        const rows = mapData.length;
551        const cols = mapData[0].length;
552        if (cols < 2 || rows < 2) return;
553        updateCell(0, 0, "╔");
554        updateCell(cols - 1, 0, "╗");
555        updateCell(0, rows - 1, "╚");
556        updateCell(cols - 1, rows - 1, "╝");
557        for (let x = 1; x < cols - 1; x += 1) {
558          updateCell(x, 0, "═");
559          updateCell(x, rows - 1, "═");
560        }
561        for (let y = 1; y < rows - 1; y += 1) {
562          updateCell(0, y, "║");
563          updateCell(cols - 1, y, "║");
564        }
565        setStatus("Added double border tiles");
566      });
567
568      saveBtn.addEventListener("click", () => {
569        const blob = new Blob([mapToText()], { type: "text/plain" });
570        const url = URL.createObjectURL(blob);
571        const link = document.createElement("a");
572        link.href = url;
573        link.download = "map.txt";
574        document.body.appendChild(link);
575        link.click();
576        link.remove();
577        URL.revokeObjectURL(url);
578        setStatus("Downloaded map.txt");
579      });
580
581      copyBtn.addEventListener("click", async () => {
582        try {
583          await navigator.clipboard.writeText(mapToText());
584          setStatus("Copied map to clipboard");
585        } catch (error) {
586          setStatus("Clipboard unavailable");
587        }
588      });
589
590      fileInput.addEventListener("change", async (event) => {
591        const file = event.target.files[0];
592        if (!file) return;
593        const text = await file.text();
594        loadFromText(text, file.name);
595      });
596
597      canvas.addEventListener("contextmenu", (event) => {
598        event.preventDefault();
599      });
600
601      const getCellFromEvent = (event) => {
602        const rect = canvas.getBoundingClientRect();
603        const x = Math.floor((event.clientX - rect.left) / cellMetrics.width);
604        const y = Math.floor((event.clientY - rect.top) / cellMetrics.height);
605        if (x < 0 || y < 0) return null;
606        if (!mapData.length) return null;
607        if (y >= mapData.length || x >= mapData[0].length) return null;
608        return { x, y };
609      };
610
611      const applyZoom = (newZoom, anchor) => {
612        const rows = mapData.length;
613        const cols = mapData[0]?.length || 0;
614        if (!rows || !cols) return;
615        const clamped = Math.min(maxZoom, Math.max(minZoom, newZoom));
616        if (clamped === zoom) return;
617        const sizeBeforeX = cellMetrics.width;
618        const sizeBeforeY = cellMetrics.height;
619        const offsetX = anchor?.x ?? gridWrap.clientWidth / 2;
620        const offsetY = anchor?.y ?? gridWrap.clientHeight / 2;
621        const worldX = (gridWrap.scrollLeft + offsetX) / sizeBeforeX;
622        const worldY = (gridWrap.scrollTop + offsetY) / sizeBeforeY;
623        zoom = clamped;
624        resizeCanvas(cols, rows);
625        renderAll();
626        gridWrap.scrollLeft = worldX * cellMetrics.width - offsetX;
627        gridWrap.scrollTop = worldY * cellMetrics.height - offsetY;
628      };
629
630      const handleCanvasEvent = (event, overrideTile = null) => {
631        const cell = getCellFromEvent(event);
632        if (!cell) return;
633        const previousTile = activeTile;
634        if (overrideTile !== null) activeTile = overrideTile;
635        if (event.shiftKey && lastCell) {
636          drawLine(lastCell, cell);
637        } else {
638          paintAt(cell.x, cell.y);
639        }
640        if (overrideTile !== null) activeTile = previousTile;
641        lastCell = cell;
642      };
643
644      canvas.addEventListener("mousedown", (event) => {
645        if (event.button === 2) {
646          isPainting = true;
647          handleCanvasEvent(event, ".");
648          return;
649        }
650        isPainting = true;
651        handleCanvasEvent(event);
652      });
653
654      canvas.addEventListener("mousemove", (event) => {
655        if (!isPainting) return;
656        if (event.buttons === 2) {
657          handleCanvasEvent(event, ".");
658          return;
659        }
660        handleCanvasEvent(event);
661      });
662
663      canvas.addEventListener("wheel", (event) => {
664        event.preventDefault();
665        const delta = event.deltaY > 0 ? -0.1 : 0.1;
666        const rect = canvas.getBoundingClientRect();
667        const anchor = {
668          x: event.clientX - rect.left,
669          y: event.clientY - rect.top,
670        };
671        applyZoom(zoom + delta, anchor);
672      });
673
674      document.addEventListener("mouseup", () => {
675        isPainting = false;
676        lastCell = null;
677      });
678
679      document.addEventListener("keydown", (event) => {
680        if (event.target.matches("input, textarea")) return;
681        if (event.key >= "1" && event.key <= "4") {
682          const tile = tileOrder[Number(event.key) - 1];
683          setActiveTile(tile);
684        }
685      });
686
687      setupPalette();
688      if (!loadState()) loadDefaultMap();
689      autoSaveId = setInterval(saveState, 5000);
690    </script>
691  </body>
692</html>