Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Isometric City</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --font: 'Inter', -apple-system, sans-serif; | |
| } | |
| *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { height: 100%; overflow: hidden; background: #f0efe8; font-family: var(--font); } | |
| canvas { display: block; cursor: grab; } | |
| canvas.grabbing { cursor: grabbing; } | |
| /* Modal */ | |
| #modal-overlay { | |
| display: none; | |
| position: fixed; inset: 0; | |
| background: rgba(0,0,0,0.6); | |
| z-index: 100; | |
| align-items: center; justify-content: center; | |
| } | |
| #modal-overlay.visible { display: flex; } | |
| #modal { | |
| background: #1a1a1a; | |
| color: #fff; | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| width: 380px; | |
| max-width: 90vw; | |
| font-family: var(--font); | |
| } | |
| #modal h2 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin-bottom: 0.25rem; | |
| } | |
| #modal .modal-sub { | |
| font-size: 0.75rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| } | |
| #modal input { | |
| width: 100%; | |
| padding: 0.6rem 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| background: #111; | |
| color: #fff; | |
| font-family: var(--font); | |
| font-size: 0.85rem; | |
| outline: none; | |
| margin-bottom: 0.75rem; | |
| } | |
| #modal input:focus { border-color: #555; } | |
| #modal .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } | |
| #modal button { | |
| padding: 0.5rem 1rem; | |
| border: none; | |
| border-radius: 8px; | |
| font-family: var(--font); | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| } | |
| #modal-cancel { background: #333; color: #aaa; } | |
| #modal-build { background: #fff; color: #000; font-weight: 600; } | |
| #modal-build:disabled { opacity: 0.4; cursor: not-allowed; } | |
| /* HUD */ | |
| #hud { | |
| position: fixed; | |
| top: 12px; left: 12px; | |
| background: rgba(0,0,0,0.75); | |
| color: #fff; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 8px; | |
| font-size: 0.75rem; | |
| pointer-events: none; | |
| z-index: 50; | |
| line-height: 1.5; | |
| } | |
| /* Toast */ | |
| #toast { | |
| display: none; | |
| position: fixed; | |
| bottom: 16px; left: 50%; transform: translateX(-50%); | |
| background: #1a1a1a; | |
| color: #fff; | |
| padding: 0.5rem 1rem; | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| z-index: 200; | |
| } | |
| #toast.visible { display: block; } | |
| #toast.error { border: 1px solid #661111; color: #ff6b6b; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="canvas"></canvas> | |
| <div id="hud"> | |
| <div>Isometric City</div> | |
| <div id="hud-info">Click a tile to build</div> | |
| </div> | |
| <div id="modal-overlay"> | |
| <div id="modal"> | |
| <h2>Build on tile</h2> | |
| <p class="modal-sub" id="modal-coords"></p> | |
| <input id="modal-prompt" placeholder="Describe your building... e.g. medieval castle" /> | |
| <div class="modal-actions"> | |
| <button id="modal-cancel">Cancel</button> | |
| <button id="modal-build">Build</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toast"></div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| // ZeroGPU handshake (not needed for CPU Space, but harmless) | |
| const ZGH = "supports-zerogpu-headers"; | |
| window.addEventListener("message", e => { if (e.data === ZGH) window.supports_zerogpu_headers = true; }); | |
| const hn = location.hostname; | |
| if (hn.endsWith(".hf.space") || hn.includes(".dev.")) { | |
| const o = hn.includes(".dev.") ? `https://moon-${hn.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co"; | |
| parent.postMessage(ZGH, o); | |
| } | |
| // ---- Constants ---- | |
| const TILE_W = 128; | |
| const TILE_H = 64; | |
| const IMG_SIZE = 140; // rendered building size on canvas | |
| // ---- State ---- | |
| const grid = {}; // "x,y" -> { prompt, seed, ts, img (Image object or null), loading } | |
| let offsetX = 0, offsetY = 0; | |
| let dragging = false, dragStartX = 0, dragStartY = 0, dragStartOX = 0, dragStartOY = 0; | |
| let dragMoved = false; | |
| let client = null; | |
| let pendingTile = null; // {x, y} for the modal | |
| // ---- Canvas setup ---- | |
| const canvas = document.getElementById("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| function resize() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| offsetX = canvas.width / 2; | |
| offsetY = canvas.height / 3; | |
| draw(); | |
| } | |
| window.addEventListener("resize", resize); | |
| // ---- Isometric math ---- | |
| function gridToScreen(gx, gy) { | |
| return [ | |
| (gx - gy) * TILE_W / 2 + offsetX, | |
| (gx + gy) * TILE_H / 2 + offsetY, | |
| ]; | |
| } | |
| function screenToGrid(sx, sy) { | |
| const ax = sx - offsetX; | |
| const ay = sy - offsetY; | |
| const gx = (ax / (TILE_W / 2) + ay / (TILE_H / 2)) / 2; | |
| const gy = (ay / (TILE_H / 2) - ax / (TILE_W / 2)) / 2; | |
| return [Math.floor(gx), Math.floor(gy)]; | |
| } | |
| // ---- Drawing ---- | |
| function draw() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = "#f0efe8"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Determine visible tile range | |
| const [minGx, minGy] = screenToGrid(0, 0); | |
| const [maxGx1, maxGy1] = screenToGrid(canvas.width, canvas.height); | |
| const [maxGx2, minGy2] = screenToGrid(canvas.width, 0); | |
| const [minGx2, maxGy2] = screenToGrid(0, canvas.height); | |
| const pad = 3; | |
| const startX = Math.min(minGx, minGx2) - pad; | |
| const endX = Math.max(maxGx1, maxGx2) + pad; | |
| const startY = Math.min(minGy, minGy2) - pad; | |
| const endY = Math.max(maxGy1, maxGy2) + pad; | |
| // Draw grid lines | |
| ctx.strokeStyle = "rgba(0,0,0,0.12)"; | |
| ctx.lineWidth = 1; | |
| for (let gx = startX; gx <= endX; gx++) { | |
| for (let gy = startY; gy <= endY; gy++) { | |
| const [cx, cy] = gridToScreen(gx, gy); | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy - TILE_H / 2); | |
| ctx.lineTo(cx + TILE_W / 2, cy); | |
| ctx.lineTo(cx, cy + TILE_H / 2); | |
| ctx.lineTo(cx - TILE_W / 2, cy); | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| } | |
| // Draw buildings (sorted by gy+gx for depth ordering) | |
| const entries = Object.entries(grid) | |
| .filter(([, v]) => v.img || v.loading) | |
| .sort(([a], [b]) => { | |
| const [ax, ay] = a.split(",").map(Number); | |
| const [bx, by] = b.split(",").map(Number); | |
| return (ax + ay) - (bx + by); | |
| }); | |
| for (const [key, val] of entries) { | |
| const [gx, gy] = key.split(",").map(Number); | |
| const [cx, cy] = gridToScreen(gx, gy); | |
| if (val.loading && !val.img) { | |
| // Pulsing placeholder | |
| const pulse = 0.4 + 0.3 * Math.sin(Date.now() / 300); | |
| ctx.fillStyle = `rgba(100, 140, 255, ${pulse})`; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy - TILE_H / 2); | |
| ctx.lineTo(cx + TILE_W / 2, cy); | |
| ctx.lineTo(cx, cy + TILE_H / 2); | |
| ctx.lineTo(cx - TILE_W / 2, cy); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| ctx.font = "10px Inter, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText("generating...", cx, cy + 4); | |
| } | |
| if (val.img) { | |
| // Draw building image — bottom-center aligned to tile center | |
| const iw = IMG_SIZE; | |
| const ih = IMG_SIZE; | |
| ctx.drawImage(val.img, cx - iw / 2, cy - ih + TILE_H / 2, iw, ih); | |
| } | |
| } | |
| // Animate if there are loading tiles | |
| if (entries.some(([, v]) => v.loading && !v.img)) { | |
| requestAnimationFrame(draw); | |
| } | |
| } | |
| // ---- Pan ---- | |
| canvas.addEventListener("mousedown", (e) => { | |
| dragging = true; | |
| dragMoved = false; | |
| dragStartX = e.clientX; | |
| dragStartY = e.clientY; | |
| dragStartOX = offsetX; | |
| dragStartOY = offsetY; | |
| canvas.classList.add("grabbing"); | |
| }); | |
| window.addEventListener("mousemove", (e) => { | |
| if (!dragging) return; | |
| const dx = e.clientX - dragStartX; | |
| const dy = e.clientY - dragStartY; | |
| if (Math.abs(dx) + Math.abs(dy) > 4) dragMoved = true; | |
| offsetX = dragStartOX + dx; | |
| offsetY = dragStartOY + dy; | |
| draw(); | |
| }); | |
| window.addEventListener("mouseup", () => { | |
| dragging = false; | |
| canvas.classList.remove("grabbing"); | |
| }); | |
| // ---- Click to build ---- | |
| canvas.addEventListener("click", (e) => { | |
| if (dragMoved) return; | |
| const [gx, gy] = screenToGrid(e.clientX, e.clientY); | |
| const key = `${gx},${gy}`; | |
| if (grid[key]) { | |
| // Tile occupied — maybe show info later | |
| return; | |
| } | |
| pendingTile = { x: gx, y: gy }; | |
| document.getElementById("modal-coords").textContent = `Tile (${gx}, ${gy})`; | |
| document.getElementById("modal-prompt").value = ""; | |
| document.getElementById("modal-overlay").classList.add("visible"); | |
| document.getElementById("modal-prompt").focus(); | |
| }); | |
| // ---- Modal ---- | |
| const modalOverlay = document.getElementById("modal-overlay"); | |
| const modalPrompt = document.getElementById("modal-prompt"); | |
| const modalBuild = document.getElementById("modal-build"); | |
| const modalCancel = document.getElementById("modal-cancel"); | |
| function closeModal() { | |
| modalOverlay.classList.remove("visible"); | |
| pendingTile = null; | |
| } | |
| modalCancel.addEventListener("click", closeModal); | |
| modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); }); | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key === "Escape") closeModal(); | |
| }); | |
| modalPrompt.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && modalPrompt.value.trim()) { | |
| e.preventDefault(); | |
| startBuild(); | |
| } | |
| }); | |
| modalBuild.addEventListener("click", () => { | |
| if (modalPrompt.value.trim()) startBuild(); | |
| }); | |
| async function startBuild() { | |
| const { x, y } = pendingTile; | |
| const prompt = modalPrompt.value.trim(); | |
| closeModal(); | |
| const key = `${x},${y}`; | |
| grid[key] = { prompt, loading: true, img: null }; | |
| draw(); // start animation loop | |
| try { | |
| const result = await client.predict("/place_building", { x, y, prompt }); | |
| const data = JSON.parse(result.data[0]); | |
| if (data.error) { | |
| delete grid[key]; | |
| showToast(data.error, true); | |
| draw(); | |
| return; | |
| } | |
| // Load the image | |
| grid[key].loading = false; | |
| grid[key].seed = data.seed; | |
| const img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| img.onload = () => { grid[key].img = img; draw(); }; | |
| img.onerror = () => { showToast("Failed to load image", true); draw(); }; | |
| img.src = data.image; | |
| } catch (e) { | |
| delete grid[key]; | |
| showToast(e.message || "Build failed", true); | |
| draw(); | |
| } | |
| } | |
| // ---- Load existing grid ---- | |
| async function loadGrid() { | |
| try { | |
| const result = await client.predict("/get_grid", {}); | |
| const data = JSON.parse(result.data[0]); | |
| for (const [key, val] of Object.entries(data)) { | |
| const [x, y] = key.split(",").map(Number); | |
| grid[key] = { ...val, loading: false, img: null }; | |
| const img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| img.onload = () => { grid[key].img = img; draw(); }; | |
| img.src = `/images/${x}_${y}.webp`; | |
| } | |
| draw(); | |
| document.getElementById("hud-info").textContent = | |
| `${Object.keys(data).length} buildings · Click a tile to build`; | |
| } catch (e) { | |
| showToast("Failed to load grid: " + e.message, true); | |
| } | |
| } | |
| // ---- Toast ---- | |
| let toastTimer = null; | |
| function showToast(msg, isError) { | |
| const t = document.getElementById("toast"); | |
| t.textContent = msg; | |
| t.className = isError ? "visible error" : "visible"; | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => { t.className = ""; }, 4000); | |
| } | |
| // ---- Init ---- | |
| async function init() { | |
| resize(); | |
| try { | |
| client = await Client.connect(window.location.origin); | |
| await loadGrid(); | |
| } catch (e) { | |
| showToast("Failed to connect: " + e.message, true); | |
| } | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |