<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no”>
<title>Dungeon Sketcher Pro</title>
<script src=”https://cdn.tailwindcss.com”></script>
<style>
body {
background-color: #121212;
user-select: none;
touch-action: none;
color: #e5e7eb;
font-family: ‘Segoe UI’, Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
overflow: hidden;
}
#viewport {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
background-color: #1a1a1a;
}
#canvas-wrapper {
position: absolute;
transform-origin: 0 0;
}
canvas {
display: block;
cursor: crosshair;
background-color: #f4e4bc;
}
.tool-btn {
transition: all 0.1s ease-out;
border: 1px solid transparent;
}
.tool-btn.active {
background-color: #d97706 !important;
color: white !important;
border-color: #f59e0b;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(217, 119, 6, 0.4);
}
.sidebar {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 50;
box-shadow: 10px 0 30px rgba(0,0,0,0.5);
backdrop-filter: blur(12px);
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: #444; border-radius: 10px; }
.sidebar.hidden-menu { transform: translateX(-100%); box-shadow: none; }
.floating-toggle { position: fixed; bottom: 1.5rem; left: 1.5rem; z-index: 100; }
/* Export Modal */
#snapshot-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.98);
z-index: 200;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
}
.modal-content {
background: #1c1917;
border: 1px solid #44403c;
border-radius: 12px;
padding: 16px;
max-width: 95%;
max-height: 95vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
#snapshot-img {
max-width: 100%;
max-height: 50vh;
border: 4px solid #fff;
background-color: #f4e4bc;
object-fit: contain;
pointer-events: auto;
}
.status-badge {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(0,0,0,0.8);
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.7rem;
pointer-events: none;
z-index: 40;
border: 1px solid rgba(255,255,255,0.1);
}
label { color: #9ca3af; font-size: 0.6rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; display: block; margin-bottom: 0.3rem; }
</style>
</head>
<body class=”flex h-screen”>
<div class=”status-badge” id=”status”>Inking…</div>
<aside id=”menu” class=”sidebar w-64 bg-stone-900/90 border-r border-stone-800 p-5 flex flex-col gap-4 overflow-y-auto”>
<div class=”mb-2″>
<h1 class=”text-xl font-serif font-bold text-amber-500″>Dungeon Sketcher</h1>
<p class=”text-[9px] text-stone-500 uppercase tracking-[0.2em]”>Ink & Paper v6.5</p>
</div>
<div>
<label>Architecture</label>
<div class=”grid grid-cols-2 gap-2″>
<button onclick=”setMode(‘room’)” id=”btn-room” class=”tool-btn active bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🎨</span>Room</button>
<button onclick=”setMode(‘door’)” id=”btn-door” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🚪</span>Door</button>
<button onclick=”setMode(‘thinwall’)” id=”btn-thinwall” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>📏</span>Divider</button>
<button onclick=”setMode(‘stairs’)” id=”btn-stairs” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🪜</span>Stairs</button>
</div>
</div>
<div>
<label>Terrain</label>
<div class=”grid grid-cols-3 gap-2″>
<button onclick=”setMode(‘cavern’)” id=”btn-cavern” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>⛰️</span></button>
<button onclick=”setMode(‘water’)” id=”btn-water” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🌊</span></button>
<button onclick=”setMode(‘lava’)” id=”btn-lava” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🔥</span></button>
</div>
</div>
<div>
<label>Monsters</label>
<div class=”grid grid-cols-3 gap-2″>
<button onclick=”setMode(‘mon_skull’)” id=”btn-mon_skull” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>💀</span></button>
<button onclick=”setMode(‘mon_slime’)” id=”btn-mon_slime” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>💧</span></button>
<button onclick=”setMode(‘mon_eye’)” id=”btn-mon_eye” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>👁️</span></button>
<button onclick=”setMode(‘mon_ghost’)” id=”btn-mon_ghost” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>👻</span></button>
<button onclick=”setMode(‘mon_boss’)” id=”btn-mon_boss” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>👹</span></button>
</div>
</div>
<div>
<label>Items</label>
<div class=”grid grid-cols-3 gap-2″>
<button onclick=”setMode(‘chest’)” id=”btn-chest” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>🧳</span></button>
<button onclick=”setMode(‘treasure’)” id=”btn-treasure” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>💎</span></button>
<button onclick=”setMode(‘trap’)” id=”btn-trap” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>⚠️</span></button>
<button onclick=”setMode(‘pillar’)” id=”btn-pillar” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>⚪</span></button>
<button onclick=”setMode(‘fountain’)” id=”btn-fountain” class=”tool-btn bg-stone-800 p-2 rounded-lg flex flex-col items-center”><span>⛲</span></button>
</div>
</div>
<div class=”mt-auto pt-4 space-y-2 border-t border-stone-800 pb-20 md:pb-4″>
<button onclick=”clearMap()” class=”w-full bg-red-900/20 hover:bg-red-900/40 text-red-400 py-2 rounded text-[10px] font-bold uppercase”>Reset</button>
<button onclick=”showSnapshot()” class=”w-full bg-amber-600 hover:bg-amber-500 text-white py-3 rounded text-sm font-bold shadow-lg uppercase tracking-wider”>Export Map</button>
</div>
</aside>
<!– Export Modal –>
<div id=”snapshot-overlay”>
<div class=”modal-content”>
<div class=”text-center”>
<h2 class=”text-amber-500 font-bold text-lg font-serif”>Export Map</h2>
<p class=”text-stone-400 text-xs mt-1″>Option 1: Long-press image to Save</p>
</div>
<img id=”snapshot-img” src=”” alt=”Dungeon Map”>
<div class=”w-full grid grid-cols-1 gap-2″>
<p class=”text-stone-500 text-[10px] text-center uppercase tracking-widest mt-2″>Export Actions</p>
<button onclick=”downloadImage()” class=”w-full bg-stone-700 hover:bg-stone-600 text-white py-3 rounded font-bold text-sm”>Download File</button>
<button onclick=”openInNewTab()” class=”w-full bg-stone-700 hover:bg-stone-600 text-white py-3 rounded font-bold text-sm”>Open in New Tab</button>
<button onclick=”hideSnapshot()” class=”w-full bg-red-900/50 hover:bg-red-900/80 text-red-200 py-2 rounded font-bold text-xs mt-2″>Close</button>
</div>
</div>
</div>
<main id=”viewport” class=”flex-1 overflow-hidden”>
<div id=”canvas-wrapper”>
<canvas id=”dungeonCanvas”></canvas>
</div>
</main>
<button onclick=”toggleMenu()” class=”floating-toggle bg-stone-800 hover:bg-stone-700 text-amber-500 p-4 rounded-full shadow-2xl border border-stone-600 transition-all active:scale-95″>
<svg class=”w-6 h-6″ fill=”none” stroke=”currentColor” viewBox=”0 0 24 24″><path stroke-linecap=”round” stroke-linejoin=”round” stroke-width=”2″ d=”M4 6h16M4 12h16M4 18h16″></path></svg>
</button>
<script>
const canvas = document.getElementById(‘dungeonCanvas’);
const ctx = canvas.getContext(‘2d’);
const wrapper = document.getElementById(‘canvas-wrapper’);
const viewport = document.getElementById(‘viewport’);
let gridSize = 40, rows = 30, cols = 40;
let currentMode = ‘room’, isDrawing = false, lastCell = { r: -1, c: -1 };
let zoom = 1.0, panX = 40, panY = 40;
let initialPinchDist = null, initialZoom = 1.0;
let rooms = Array(rows).fill().map(() => Array(cols).fill(false));
let flavor = Array(rows).fill().map(() => Array(cols).fill(null));
let furniture = Array(rows).fill().map(() => Array(cols).fill(null));
let hDoors = Array(rows + 1).fill().map(() => Array(cols).fill(false));
let vDoors = Array(rows).fill().map(() => Array(cols + 1).fill(false));
let hThinWalls = Array(rows + 1).fill().map(() => Array(cols).fill(false));
let vThinWalls = Array(rows).fill().map(() => Array(cols + 1).fill(false));
function initCanvas() {
canvas.width = cols * gridSize;
canvas.height = rows * gridSize;
updateTransform();
render();
updateStatus();
}
function updateTransform() { wrapper.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; }
function updateZoom(val) { zoom = parseFloat(val); updateTransform(); updateStatus(); }
function updateStatus() { document.getElementById(‘status’).innerText = `${cols}x${rows} | ${Math.round(zoom * 100)}%`; }
function toggleMenu() { document.getElementById(‘menu’).classList.toggle(‘hidden-menu’); }
function setMode(mode) {
currentMode = mode;
document.querySelectorAll(‘.tool-btn’).forEach(btn => btn.classList.remove(‘active’));
document.getElementById(`btn-${mode}`)?.classList.add(‘active’);
}
// Pseudo-random generator for consistent sketchiness per cell
function pseudoRandom(x, y) {
return Math.abs(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1;
}
function drawSketchLine(x1, y1, x2, y2, color = ‘#1a1a1a’, width = 2.8, jitter = 1.2) {
ctx.beginPath(); ctx.lineWidth = width; ctx.lineCap = ’round’; ctx.strokeStyle = color;
const dx = x2 – x1, dy = y2 – y1, dist = Math.sqrt(dx*dx + dy*dy), steps = Math.max(2, Math.floor(dist / 6));
ctx.moveTo(x1, y1);
for(let i = 1; i <= steps; i++) {
const t = i / steps;
ctx.lineTo(x1 + dx * t + (Math.random()-0.5)*jitter, y1 + dy * t + (Math.random()-0.5)*jitter);
}
ctx.lineTo(x2, y2); ctx.stroke();
}
function drawSketchCircle(cx, cy, r, color = ‘#1a1a1a’, width = 2) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = ’round’;
const steps = 12;
const rVar = r * 0.1;
for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2;
const ra = r + (Math.random() – 0.5) * rVar;
const x = cx + Math.cos(a) * ra;
const y = cy + Math.sin(a) * ra;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
function drawJaggedLine(x1, y1, x2, y2, color = ‘#1a1a1a’, width = 3) {
ctx.beginPath(); ctx.lineWidth = width; ctx.strokeStyle = color;
const dx = x2-x1, dy = y2-y1, steps = Math.max(3, Math.floor(Math.sqrt(dx*dx+dy*dy)/4));
ctx.moveTo(x1, y1);
for(let i=1; i<=steps; i++){
const t = i/steps;
ctx.lineTo(x1+dx*t+(Math.random()-0.5)*6, y1+dy*t+(Math.random()-0.5)*6);
}
ctx.lineTo(x2, y2); ctx.stroke();
}
function drawEdgeDoor(x, y, isVertical) {
const aperture = gridSize * 0.45, half = aperture/2;
ctx.fillStyle = ‘#f4e4bc’;
if(isVertical){
const ty = y + (gridSize-aperture)/2, by = y + (gridSize+aperture)/2;
drawSketchLine(x, y, x, ty, ‘#1a1a1a’, 2.8, 0.5);
drawSketchLine(x, y+gridSize, x, by, ‘#1a1a1a’, 2.8, 0.5);
ctx.fillRect(x-half, ty, aperture, aperture);
drawSketchLine(x, ty, x, by, ‘#1a1a1a’, 3, 0);
drawSketchLine(x-half, ty, x+half, ty, ‘#1a1a1a’, 2, 0);
drawSketchLine(x-half, by, x+half, by, ‘#1a1a1a’, 2, 0);
} else {
const lx = x + (gridSize-aperture)/2, rx = x + (gridSize+aperture)/2;
drawSketchLine(x, y, lx, y, ‘#1a1a1a’, 2.8, 0.5);
drawSketchLine(x+gridSize, y, rx, y, ‘#1a1a1a’, 2.8, 0.5);
ctx.fillRect(lx, y-half, aperture, aperture);
drawSketchLine(lx, y, rx, y, ‘#1a1a1a’, 3, 0);
drawSketchLine(lx, y-half, lx, y+half, ‘#1a1a1a’, 2, 0);
drawSketchLine(rx, y-half, rx, y+half, ‘#1a1a1a’, 2, 0);
}
}
function render() {
// 0. BAKE PARCHMENT TEXTURE
ctx.fillStyle = ‘#f4e4bc’;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add Grain
for(let i=0; i<2000; i++) {
ctx.fillStyle = `rgba(0,0,0,${Math.random()*0.03})`;
ctx.fillRect(Math.random()*canvas.width, Math.random()*canvas.height, 1.5, 1.5);
}
// Faint Hand-Drawn Grid
ctx.strokeStyle = ‘rgba(0,0,0,0.06)’;
ctx.lineWidth = 1;
for(let i=0; i<=cols; i++){ ctx.beginPath(); ctx.moveTo(i*gridSize,0); ctx.lineTo(i*gridSize, canvas.height); ctx.stroke(); }
for(let j=0; j<=rows; j++){ ctx.beginPath(); ctx.moveTo(0, j*gridSize); ctx.lineTo(canvas.width, j*gridSize); ctx.stroke(); }
// 1. Terrain Layer
for (let r=0; r<rows; r++) {
for (let c=0; c<cols; c++) {
const x = c*gridSize, y = r*gridSize;
if(flavor[r][c] === ‘water’){ ctx.fillStyle = ‘#a8d1ff’; ctx.fillRect(x,y,gridSize,gridSize); }
else if(flavor[r][c] === ‘lava’){ ctx.fillStyle = ‘#ff7e54’; ctx.fillRect(x,y,gridSize,gridSize); }
else if(flavor[r][c] === ‘cavern’){ ctx.fillStyle = ‘#d4c4a1’; ctx.fillRect(x,y,gridSize,gridSize); }
else if(rooms[r][c]){ ctx.fillStyle = ‘rgba(255, 255, 255, 0.45)’; ctx.fillRect(x,y,gridSize,gridSize); }
}
}
// 2. Structural Walls
for (let r=0; r<rows; r++) {
for (let c=0; c<cols; c++) {
if(!rooms[r][c]) continue;
const x = c*gridSize, y = r*gridSize;
const check = (nr, nc, x1, y1, x2, y2, isH) => {
const nR = (nr>=0 && nr<rows && nc>=0 && nc<cols) ? rooms[nr][nc] : false;
const dE = isH ? hDoors[isH===’top’?r:r+1][c] : vDoors[r][isH===’left’?c:c+1];
const nf = (nr>=0 && nr<rows && nc>=0 && nc<cols) ? flavor[nr][nc] : null;
if (!nR && !dE && ![‘cavern’,’lava’].includes(nf)) drawSketchLine(x1, y1, x2, y2);
};
check(r-1,c, x,y, x+gridSize,y, ‘top’); check(r+1,c, x,y+gridSize, x+gridSize,y+gridSize, ‘bottom’);
check(r,c-1, x,y, x,y+gridSize, ‘left’); check(r,c+1, x+gridSize,y, x+gridSize,y+gridSize, ‘right’);
}
}
// 3. Doors & Thin Walls
for (let r=0; r<=rows; r++) {
for (let c=0; c<cols; c++) {
if(hDoors[r][c]) drawEdgeDoor(c*gridSize, r*gridSize, false);
if(hThinWalls[r][c]) drawSketchLine(c*gridSize, r*gridSize, (c+1)*gridSize, r*gridSize, ‘#555’, 2.2, 0.2);
}
}
for (let r=0; r<rows; r++) {
for (let c=0; c<=cols; c++) {
if(vDoors[r][c]) drawEdgeDoor(c*gridSize, r*gridSize, true);
if(vThinWalls[r][c]) drawSketchLine(c*gridSize, r*gridSize, c*gridSize, (r+1)*gridSize, ‘#555’, 2.2, 0.2);
}
}
// 4. Terrain Edges
for (let r=0; r<rows; r++) {
for (let c=0; c<cols; c++) {
const x = c*gridSize, y = r*gridSize, f = flavor[r][c];
if(![‘cavern’,’lava’].includes(f)) continue;
const ds = (nr, nc, x1,y1,x2,y2) => {
const nf = (nr>=0 && nr<rows && nc>=0 && nc<cols) ? flavor[nr][nc] : null;
if(nf !== f) drawJaggedLine(x1,y1,x2,y2, f===’lava’?’#900′:’#1a1a1a’, f===’lava’?2:3.2);
};
ds(r-1,c, x,y, x+gridSize,y); ds(r+1,c, x,y+gridSize, x+gridSize,y+gridSize);
ds(r,c-1, x,y, x,y+gridSize); ds(r,c+1, x+gridSize,y, x+gridSize,y+gridSize);
}
}
// 5. Tokens
for (let r=0; r<rows; r++) {
for (let c=0; c<cols; c++) {
const type = furniture[r][c]; if(!type) continue;
const x = c*gridSize, y = r*gridSize, cx = x+gridSize/2, cy = y+gridSize/2;
const rand = pseudoRandom(r, c);
// Varied Rotation based on cell
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((rand – 0.5) * 0.4);
ctx.translate(-cx, -cy);
// Sketchy Base
if(type.startsWith(‘mon_’) || [‘chest’,’treasure’,’trap’,’fountain’,’pillar’].includes(type)){
ctx.fillStyle = ‘#fff’;
ctx.beginPath(); ctx.arc(cx,cy,gridSize*0.35,0,Math.PI*2); ctx.fill();
drawSketchCircle(cx, cy, gridSize*0.35, ‘#111’, 1.5);
}
ctx.strokeStyle = ‘#111′; ctx.lineWidth = 2; ctx.lineCap = ’round’;
if(type === ‘mon_skull’){
ctx.beginPath();
ctx.moveTo(cx-6, cy-4);
// Sketchy Cranium
ctx.quadraticCurveTo(cx, cy-14+rand*4, cx+6, cy-4);
// Cheekbones & Jaw
ctx.lineTo(cx+5, cy+2); ctx.lineTo(cx-5, cy+2);
ctx.closePath(); ctx.stroke();
// Jaw lines
drawSketchLine(cx-3, cy+2, cx-3, cy+6, ‘#111’, 1.5);
drawSketchLine(cx, cy+2, cx, cy+6, ‘#111’, 1.5);
drawSketchLine(cx+3, cy+2, cx+3, cy+6, ‘#111’, 1.5);
// Eyes
ctx.fillStyle=’#111′;
ctx.beginPath(); ctx.arc(cx-3, cy-3, 2, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(cx+3, cy-3, 2, 0, 7); ctx.fill();
}
else if(type === ‘mon_slime’){
ctx.beginPath();
ctx.moveTo(cx-10, cy+8);
ctx.quadraticCurveTo(cx-12+rand*5, cy-12, cx, cy-14+rand*5);
ctx.quadraticCurveTo(cx+12-rand*5, cy-12, cx+10, cy+8);
ctx.closePath(); ctx.stroke();
// Bubble
ctx.beginPath(); ctx.arc(cx+4, cy-4, 2, 0, 7); ctx.stroke();
}
else if(type === ‘mon_eye’){
drawSketchCircle(cx, cy, 10, ‘#111’, 1.8); // Main eye
ctx.fillStyle=’#111′; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, 7); ctx.fill(); // Pupil
// Stalks
for(let i=0; i<5; i++){
const ang = (i/5)*Math.PI – Math.PI;
drawSketchLine(cx + Math.cos(ang)*10, cy + Math.sin(ang)*10, cx + Math.cos(ang)*16, cy + Math.sin(ang)*16, ‘#111’, 1.2);
ctx.beginPath(); ctx.arc(cx + Math.cos(ang)*18, cy + Math.sin(ang)*18, 1.5, 0, 7); ctx.fill();
}
}
else if(type === ‘mon_ghost’){
ctx.beginPath(); ctx.moveTo(cx-8, cy+8);
ctx.quadraticCurveTo(cx-8, cy-15, cx, cy-15);
ctx.quadraticCurveTo(cx+8, cy-15, cx+8, cy+8);
ctx.lineTo(cx+4, cy+4); ctx.lineTo(cx, cy+8); ctx.lineTo(cx-4, cy+4); ctx.closePath(); ctx.stroke();
ctx.fillStyle=’#111′; ctx.beginPath(); ctx.arc(cx-3, cy-4, 2, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(cx+3, cy-4, 2, 0, 7); ctx.fill();
}
else if(type === ‘mon_boss’){
drawJaggedLine(cx-12, cy+5, cx+12, cy+5, ‘#900’, 2.5); // Mouth
drawSketchLine(cx-8, cy-5, cx-16, cy-12, ‘#111’, 2.5); // Horn L
drawSketchLine(cx+8, cy-5, cx+16, cy-12, ‘#111’, 2.5); // Horn R
ctx.beginPath(); ctx.arc(cx, cy, 10, 0, 7); ctx.stroke();
ctx.fillStyle=’#900′; ctx.beginPath(); ctx.arc(cx-4, cy-2, 2, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(cx+4, cy-2, 2, 0, 7); ctx.fill();
}
else if(type === ‘chest’){
ctx.strokeRect(x+10, y+14, 20, 12);
drawSketchLine(x+10, y+20, x+30, y+20, ‘#111’, 1);
// Wood grain
drawSketchLine(x+14, y+16, x+26, y+16, ‘#555’, 0.8);
drawSketchLine(x+14, y+24, x+26, y+24, ‘#555’, 0.8);
}
else if(type === ‘treasure’){
for(let i=0; i<8; i++){
ctx.fillStyle=’#d97706′; ctx.beginPath();
ctx.arc(cx+(pseudoRandom(i,r)*20)-10, cy+(pseudoRandom(c,i)*20)-10, 2.5, 0, 7); ctx.fill();
}
}
else if(type === ‘trap’){
drawSketchLine(x+10, y+10, x+30, y+30, ‘#b91c1c’, 2.5);
drawSketchLine(x+30, y+10, x+10, y+30, ‘#b91c1c’, 2.5);
}
else if(type === ‘fountain’){
drawSketchCircle(cx, cy, 12, ‘#111’, 1.8);
drawSketchCircle(cx, cy, 6, ‘rgba(0,100,255,0.6)’, 1.5);
}
else if(type === ‘pillar’){
drawSketchCircle(cx, cy, 9, ‘#111’, 2.5);
// Cracks
if(rand > 0.5) drawSketchLine(cx-4, cy-4, cx+2, cy+2, ‘#555’, 1);
}
else if(type === ‘stairs’){
ctx.strokeRect(x+8, y+8, 24, 24);
for(let i=1; i<6; i++) drawSketchLine(x+8, y+8+(i*4), x+32, y+8+(i*4), ‘#111’, 1.5, 0);
}
ctx.restore(); // Undo rotation
}
}
}
function showSnapshot() {
render();
const img = document.getElementById(‘snapshot-img’);
img.src = canvas.toDataURL(‘image/png’);
document.getElementById(‘snapshot-overlay’).style.display = ‘flex’;
}
function hideSnapshot() { document.getElementById(‘snapshot-overlay’).style.display = ‘none’; }
function downloadImage() {
const link = document.createElement(‘a’);
link.download = `dungeon_map_${Date.now()}.png`;
link.href = canvas.toDataURL();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function openInNewTab() {
const dataUrl = canvas.toDataURL();
const win = window.open(‘about:blank’, ‘_blank’);
if (win) {
win.document.write(`<title>Dungeon Map</title><img src=”${dataUrl}” style=”width:100%”/>`);
win.document.close();
} else {
alert(‘Pop-up blocked. Please check your browser settings.’);
}
}
function getCoords(clientX, clientY) {
const rect = viewport.getBoundingClientRect();
const rx = (clientX-rect.left-panX)/zoom, ry = (clientY-rect.top-panY)/zoom;
return { r: Math.floor(ry/gridSize), c: Math.floor(rx/gridSize), rx, ry };
}
function handleInput(e, force = false) {
if (e.touches && e.touches.length > 1) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX, clientY = e.touches ? e.touches[0].clientY : e.clientY;
const { r, c, rx, ry } = getCoords(clientX, clientY);
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
if (!force && r === lastCell.r && c === lastCell.c) return;
lastCell = { r, c };
if(currentMode === ‘room’) { if(force) this.dv = !rooms[r][c]; rooms[r][c] = this.dv; if(rooms[r][c]) flavor[r][c] = null; }
else if([‘water’, ‘cavern’, ‘lava’].includes(currentMode)){ if(force) this.dv = flavor[r][c] === currentMode ? null : currentMode; flavor[r][c] = this.dv; if(flavor[r][c]) rooms[r][c] = false; }
else if([‘door’, ‘thinwall’].includes(currentMode)){
if(!force) return;
const dx = rx-(c*gridSize), dy = ry-(r*gridSize);
const ds = [{t:’h’,r,c,d:dy},{t:’h’,r:r+1,c,d:gridSize-dy},{t:’v’,r,c,d:dx},{t:’v’,r,c:c+1,d:gridSize-dx}].sort((a,b)=>a.d-b.d);
const b = ds[0]; const target = currentMode===’door’?(b.t===’h’?hDoors:vDoors):(b.t===’h’?hThinWalls:vThinWalls);
target[b.r][b.c] = !target[b.r][b.c];
} else if(force) furniture[r][c] = (furniture[r][c] === currentMode) ? null : currentMode;
render();
}
viewport.addEventListener(‘touchstart’, (e) => {
if (e.touches.length === 2) {
isDrawing = false; initialPinchDist = Math.hypot(e.touches[0].pageX – e.touches[1].pageX, e.touches[0].pageY – e.touches[1].pageY);
initialZoom = zoom; this.lX = (e.touches[0].pageX + e.touches[1].pageX)/2; this.lY = (e.touches[0].pageY + e.touches[1].pageY)/2;
} else { isDrawing = true; handleInput(e, true); }
}, { passive: false });
viewport.addEventListener(‘touchmove’, (e) => {
e.preventDefault();
if (e.touches.length === 2 && initialPinchDist) {
const dist = Math.hypot(e.touches[0].pageX – e.touches[1].pageX, e.touches[0].pageY – e.touches[1].pageY);
updateZoom(Math.max(0.2, Math.min(3, initialZoom * (dist / initialPinchDist))));
const cx = (e.touches[0].pageX + e.touches[1].pageX)/2, cy = (e.touches[0].pageY + e.touches[1].pageY)/2;
panX += cx – this.lX; panY += cy – this.lY; this.lX = cx; this.lY = cy; updateTransform();
} else if (isDrawing) handleInput(e);
}, { passive: false });
viewport.addEventListener(‘touchend’, () => { isDrawing = false; lastCell = {r:-1, c:-1}; });
viewport.addEventListener(‘mousedown’, (e) => { if(e.button===1 || (e.button===0 && e.altKey)) this.isPan = true; else { isDrawing=true; handleInput(e, true); } });
window.addEventListener(‘mousemove’, (e) => { if(this.isPan){ panX += e.movementX; panY += e.movementY; updateTransform(); } else if(isDrawing) handleInput(e); });
window.addEventListener(‘mouseup’, () => { this.isPan = false; isDrawing = false; lastCell = {r:-1, c:-1}; });
viewport.addEventListener(‘wheel’, (e) => { e.preventDefault(); updateZoom(Math.max(0.2, Math.min(3, zoom * (e.deltaY>0?0.9:1.1)))); }, { passive: false });
function clearMap() { if(!confirm(“Erase all progress?”)) return; rooms=rooms.map(r=>r.fill(false)); flavor=flavor.map(r=>r.fill(null)); furniture=furniture.map(r=>r.fill(null)); hDoors=hDoors.map(r=>r.fill(false)); vDoors=vDoors.map(r=>r.fill(false)); hThinWalls=hThinWalls.map(r=>r.fill(false)); vThinWalls=vThinWalls.map(r=>r.fill(false)); render(); }
initCanvas();
</script>
</body>
</html>
