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>