Skip to content
Deasy Corporation
Logos
Theos
Work
Deasy Corporation
2026 AD
Games
Placement Mode State Machine POC
Back to Gaming Concepts
<html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>POC — Placement Mode State Machine</title> <style> :root { color-scheme: dark; } html, body { margin:0; height:100%; background:#000; overflow:hidden; font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; } #app { width:1200px; height:1200px; margin:0 auto; position:relative; background:#07080a; } canvas { display:block; width:100%; height:100%; } /* Mode banner */ #banner{ position:absolute; left:50%; top:14px; transform:translateX(-50%); padding:10px 14px; border-radius:999px; border:1px solid rgba(255,255,255,.14); background:rgba(0,0,0,.55); font-weight:700; letter-spacing:.4px; user-select:none; pointer-events:none; backdrop-filter: blur(6px); font-size:12px; text-transform:uppercase; } /* HUD */ #hud { position:absolute; left:12px; top:12px; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.12); border-radius:12px; padding:10px 12px; font-size:13px; line-height:1.35; backdrop-filter: blur(6px); user-select:none; pointer-events:none; max-width:680px; } #hint { position:absolute; left:12px; bottom:12px; background:rgba(0,0,0,.40); border:1px solid rgba(255,255,255,.10); border-radius:12px; padding:10px 12px; font-size:12px; line-height:1.35; user-select:none; pointer-events:none; } /* Full-screen placement tint overlay */ #tint { position:absolute; inset:0; background:rgba(255,255,255,0.06); opacity:0; transition: opacity 80ms linear; pointer-events:none; } .k { opacity:.7; } .v { font-variant-numeric: tabular-nums; } </style> </head> <body> <div id="app"> <canvas id="c" width="1200" height="1200"></canvas> <div id="tint"></div> <div id="banner">NAV MODE</div> <div id="hud"> <div><span class="k">Mode:</span> <span class="v" id="mode">NAV</span></div> <div><span class="k">World (cursor):</span> <span class="v" id="wld">—</span></div> <div><span class="k">Camera (world):</span> <span class="v" id="cam">—</span></div> <div><span class="k">Zoom:</span> <span class="v" id="zm">—</span></div> <div><span class="k">Placed objects:</span> <span class="v" id="cnt">0</span></div> </div> <div id="hint"> <div><b>Controls</b></div> <div><span class="k">Enter placement mode:</span> P</div> <div><span class="k">Commit (only in placement mode):</span> Left-click (places 1 and exits)</div> <div><span class="k">Cancel (only in placement mode):</span> ESC or Right-click (exits, places nothing)</div> <div><span class="k">Pan (only in nav mode):</span> Right-mouse drag</div> <div><span class="k">Zoom:</span> Mouse wheel (zooms toward cursor)</div> <div><span class="k">Undo last:</span> Z</div> <div><span class="k">Clear all:</span> C</div> <div><span class="k">Reset camera:</span> R</div> </div> </div> <script> (() => { const canvas = document.getElementById("c"); const ctx = canvas.getContext("2d", { alpha: false }); const elMode = document.getElementById("mode"); const elWld = document.getElementById("wld"); const elCam = document.getElementById("cam"); const elZm = document.getElementById("zm"); const elCnt = document.getElementById("cnt"); const banner = document.getElementById("banner"); const tint = document.getElementById("tint"); const VW = canvas.width, VH = canvas.height; const WORLD_W = 4096, WORLD_H = 4096; // Camera (world center) let camX = WORLD_W * 0.5; let camY = WORLD_H * 0.5; let zoom = 0.7; // Cursor let mousePX = VW * 0.5; let mousePY = VH * 0.5; let mouseWX = camX; let mouseWY = camY; // Mode const MODE = { NAV: "NAV", PLACE: "PLACE" }; let mode = MODE.NAV; // Pan state (only allowed in NAV) let panning = false; let panStartPX = 0, panStartPY = 0; let panStartCamX = 0, panStartCamY = 0; // Ghost settings (world units) let ghostW = 160, ghostH = 120; // Placed objects const placed = []; // {x,y,w,h} function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function viewHalfWorldW() { return (VW * 0.5) / zoom; } function viewHalfWorldH() { return (VH * 0.5) / zoom; } function clampCamera() { const hw = viewHalfWorldW(); const hh = viewHalfWorldH(); const minX = hw, maxX = WORLD_W - hw; const minY = hh, maxY = WORLD_H - hh; if (minX <= maxX) camX = clamp(camX, minX, maxX); else camX = WORLD_W * 0.5; if (minY <= maxY) camY = clamp(camY, minY, maxY); else camY = WORLD_H * 0.5; } function screenToWorld(px, py) { const dx = (px - VW * 0.5) / zoom; const dy = (py - VH * 0.5) / zoom; return { x: camX + dx, y: camY + dy }; } function worldToScreen(wx, wy) { const sx = (wx - camX) * zoom + VW * 0.5; const sy = (wy - camY) * zoom + VH * 0.5; return { x: sx, y: sy }; } function setMouseFromEvent(e) { const r = canvas.getBoundingClientRect(); mousePX = clamp(e.clientX - r.left, 0, VW); mousePY = clamp(e.clientY - r.top, 0, VH); const w = screenToWorld(mousePX, mousePY); mouseWX = w.x; mouseWY = w.y; } function zoomTowardCursor(newZoom, anchorPX, anchorPY) { const before = screenToWorld(anchorPX, anchorPY); zoom = clamp(newZoom, 0.2, 3.0); const after = screenToWorld(anchorPX, anchorPY); camX += (before.x - after.x); camY += (before.y - after.y); clampCamera(); const w = screenToWorld(mousePX, mousePY); mouseWX = w.x; mouseWY = w.y; } function setMode(next) { mode = next; if (mode === MODE.NAV) { banner.textContent = "NAV MODE"; tint.style.opacity = "0"; canvas.style.cursor = "default"; panning = false; } else { banner.textContent = "PLACEMENT MODE — LMB COMMIT / ESC OR RMB CANCEL"; tint.style.opacity = "1"; canvas.style.cursor = "crosshair"; panning = false; } } function ghostRectWorld() { return { x: mouseWX - ghostW/2, y: mouseWY - ghostH/2, w: ghostW, h: ghostH }; } function commitPlacement() { const g = ghostRectWorld(); placed.push({ x: g.x, y: g.y, w: g.w, h: g.h }); setMode(MODE.NAV); } function cancelPlacement() { setMode(MODE.NAV); } function drawGrid() { const hw = viewHalfWorldW(); const hh = viewHalfWorldH(); const left = camX - hw, right = camX + hw; const top = camY - hh, bottom = camY + hh; const targetPx = 80; let step = targetPx / zoom; const nice = [16, 32, 64, 128, 256, 512, 1024]; step = nice.reduce((best, v) => (Math.abs(v - step) < Math.abs(best - step) ? v : best), nice[0]); const startX = Math.floor(left / step) * step; const startY = Math.floor(top / step) * step; ctx.save(); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(255,255,255,0.06)"; ctx.beginPath(); for (let x = startX; x <= right; x += step) { const s = worldToScreen(x, 0).x; ctx.moveTo(s, 0); ctx.lineTo(s, VH); } for (let y = startY; y <= bottom; y += step) { const s = worldToScreen(0, y).y; ctx.moveTo(0, s); ctx.lineTo(VW, s); } ctx.stroke(); // World bounds const tl = worldToScreen(0, 0); const br = worldToScreen(WORLD_W, WORLD_H); ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 2; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.restore(); } function drawPlaced() { ctx.save(); ctx.fillStyle = "rgba(255,255,255,0.18)"; ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2; for (let i = 0; i < placed.length; i++) { const o = placed[i]; const p = worldToScreen(o.x, o.y); const wpx = o.w * zoom; const hpx = o.h * zoom; ctx.fillRect(p.x, p.y, wpx, hpx); ctx.strokeRect(p.x, p.y, wpx, hpx); ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "12px system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif"; ctx.fillText(String(i+1), p.x + 6, p.y + 16); ctx.fillStyle = "rgba(255,255,255,0.18)"; } ctx.restore(); } function drawGhost() { if (mode !== MODE.PLACE) return; const g = ghostRectWorld(); const p = worldToScreen(g.x, g.y); const wpx = g.w * zoom; const hpx = g.h * zoom; ctx.save(); // Stronger ghost (obvious) ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(p.x, p.y, wpx, hpx); ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2; ctx.strokeRect(p.x, p.y, wpx, hpx); // Center marker const c = worldToScreen(mouseWX, mouseWY); ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); ctx.arc(c.x, c.y, 4, 0, Math.PI*2); ctx.fill(); // Corner ticks ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2; const t = 10; ctx.beginPath(); // TL ctx.moveTo(p.x, p.y+t); ctx.lineTo(p.x, p.y); ctx.lineTo(p.x+t, p.y); // TR ctx.moveTo(p.x+wpx-t, p.y); ctx.lineTo(p.x+wpx, p.y); ctx.lineTo(p.x+wpx, p.y+t); // BL ctx.moveTo(p.x, p.y+hpx-t); ctx.lineTo(p.x, p.y+hpx); ctx.lineTo(p.x+t, p.y+hpx); // BR ctx.moveTo(p.x+wpx-t, p.y+hpx); ctx.lineTo(p.x+wpx, p.y+hpx); ctx.lineTo(p.x+wpx, p.y+hpx-t); ctx.stroke(); ctx.restore(); } function render() { ctx.fillStyle = "#07080a"; ctx.fillRect(0, 0, VW, VH); drawGrid(); drawPlaced(); drawGhost(); elMode.textContent = mode; elWld.textContent = `${mouseWX.toFixed(2)}, ${mouseWY.toFixed(2)}`; elCam.textContent = `${camX.toFixed(2)}, ${camY.toFixed(2)}`; elZm.textContent = `${zoom.toFixed(3)}x`; elCnt.textContent = String(placed.length); requestAnimationFrame(render); } // Events canvas.addEventListener("mousemove", (e) => { setMouseFromEvent(e); if (panning && mode === MODE.NAV) { const dx = (mousePX - panStartPX) / zoom; const dy = (mousePY - panStartPY) / zoom; camX = panStartCamX - dx; camY = panStartCamY - dy; clampCamera(); const w = screenToWorld(mousePX, mousePY); mouseWX = w.x; mouseWY = w.y; } }); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("mousedown", (e) => { setMouseFromEvent(e); // LMB if (e.button === 0) { if (mode === MODE.PLACE) commitPlacement(); return; } // RMB if (e.button === 2) { if (mode === MODE.PLACE) { cancelPlacement(); return; } // NAV pan only panning = true; panStartPX = mousePX; panStartPY = mousePY; panStartCamX = camX; panStartCamY = camY; } }); window.addEventListener("mouseup", () => { panning = false; }); canvas.addEventListener("wheel", (e) => { e.preventDefault(); setMouseFromEvent(e); const delta = Math.sign(e.deltaY); const factor = (delta > 0) ? 0.90 : 1.10; zoomTowardCursor(zoom * factor, mousePX, mousePY); }, { passive: false }); window.addEventListener("keydown", (e) => { const k = e.key.toLowerCase(); if (k === "p") { if (mode === MODE.NAV) setMode(MODE.PLACE); else setMode(MODE.NAV); return; } if (k === "escape") { if (mode === MODE.PLACE) cancelPlacement(); return; } if (k === "z") { placed.pop(); return; } if (k === "c") { placed.length = 0; return; } if (k === "r") { camX = WORLD_W * 0.5; camY = WORLD_H * 0.5; zoom = 0.7; clampCamera(); return; } }); clampCamera(); setMode(MODE.NAV); render(); })(); </script> </body> </html>' ></iframe> </div>