Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Map Builder</title> | |
| <link rel="icon" type="image/png" href="logo.png?v=2"> | |
| <!-- Google Fonts --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| /* --- CSS Reset & Variables --- */ | |
| :root { | |
| --font-heading: 'Outfit', sans-serif; | |
| --font-body: 'Plus Jakarta Sans', sans-serif; | |
| --font-mono: 'JetBrains Mono', monospace; | |
| /* Dark Theme Colors (Default) */ | |
| --bg-page: #0b0f19; | |
| --bg-card: rgba(17, 24, 39, 0.75); | |
| --bg-sidebar: rgba(10, 14, 23, 0.9); | |
| --border-color: rgba(255, 255, 255, 0.08); | |
| --text-main: #f3f4f6; | |
| --text-muted: #9ca3af; | |
| --text-inverse: #0b0f19; | |
| --primary: #6366f1; | |
| --primary-glow: rgba(99, 102, 241, 0.15); | |
| --secondary: #a855f7; | |
| --accent-cyan: #06b6d4; | |
| --accent-green: #10b981; | |
| --accent-orange: #f97316; | |
| --accent-red: #ef4444; | |
| --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); | |
| --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.3); | |
| --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.2); | |
| --transition-fast: 0.2s ease; | |
| --transition-normal: 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| --glass-bg: rgba(255, 255, 255, 0.03); | |
| --glass-blur: blur(12px); | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| html { | |
| scroll-behavior: smooth; | |
| } | |
| body { | |
| background-color: var(--bg-page); | |
| color: var(--text-main); | |
| font-family: var(--font-body); | |
| line-height: 1.6; | |
| overflow-x: hidden; | |
| transition: background-color var(--transition-normal), color var(--transition-normal); | |
| position: relative; | |
| min-height: 100vh; | |
| } | |
| /* --- Custom Scrollbar --- */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: var(--bg-page); | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--border-color); | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--primary); | |
| } | |
| /* --- Dynamic Animated Background Glows --- */ | |
| .bg-glows { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| pointer-events: none; | |
| z-index: 0; | |
| overflow: hidden; | |
| } | |
| .glow-1 { | |
| position: absolute; | |
| top: -10%; | |
| right: 5%; | |
| width: 50vw; | |
| height: 50vw; | |
| background: radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%); | |
| filter: blur(80px); | |
| animation: floatGlow 15s infinite alternate; | |
| } | |
| .glow-2 { | |
| position: absolute; | |
| bottom: -10%; | |
| left: 10%; | |
| width: 45vw; | |
| height: 45vw; | |
| background: radial-gradient(circle, rgba(168, 85, 247, 0.06) 0%, transparent 70%); | |
| filter: blur(80px); | |
| animation: floatGlow 20s infinite alternate-reverse; | |
| } | |
| @keyframes floatGlow { | |
| 0% { | |
| transform: translate(0, 0) scale(1); | |
| } | |
| 100% { | |
| transform: translate(40px, -30px) scale(1.1); | |
| } | |
| } | |
| /* --- Container --- */ | |
| .app-container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 2rem; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| /* --- Header --- */ | |
| header { | |
| margin-bottom: 2.5rem; | |
| border-bottom: 1px solid var(--border-color); | |
| padding-bottom: 1.5rem; | |
| position: relative; | |
| text-align: center; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .badge-header { | |
| display: inline-flex; | |
| background: linear-gradient(90deg, var(--primary-glow), transparent); | |
| border-left: 3px solid var(--primary); | |
| padding: 0.35rem 0.75rem; | |
| font-size: 0.75rem; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: var(--primary); | |
| margin-bottom: 0.75rem; | |
| border-radius: 0 4px 4px 0; | |
| } | |
| h1 { | |
| font-family: var(--font-heading); | |
| font-size: 2.5rem; | |
| font-weight: 800; | |
| background: linear-gradient(135deg, var(--text-main) 30%, var(--primary) 70%, var(--secondary) 100%); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| letter-spacing: -1px; | |
| margin-bottom: 0.5rem; | |
| text-align: center; | |
| } | |
| .subtitle { | |
| font-size: 1.05rem; | |
| color: var(--text-muted); | |
| font-weight: 400; | |
| text-align: center; | |
| } | |
| /* --- Toast Notifications --- */ | |
| .toast-container { | |
| position: fixed; | |
| top: 2rem; | |
| right: 2rem; | |
| z-index: 9999; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| pointer-events: none; | |
| } | |
| .toast { | |
| pointer-events: auto; | |
| background: rgba(15, 23, 42, 0.9); | |
| backdrop-filter: blur(16px) saturate(180%); | |
| -webkit-backdrop-filter: blur(16px) saturate(180%); | |
| border: 1px solid var(--border-color); | |
| color: var(--text-main); | |
| padding: 1rem 1.5rem; | |
| border-radius: 12px; | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.4); | |
| transform: translateY(-20px); | |
| opacity: 0; | |
| transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s ease; | |
| max-width: 380px; | |
| } | |
| .toast.show { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| .toast.success { | |
| border-left: 4px solid var(--accent-green); | |
| } | |
| .toast.error { | |
| border-left: 4px solid var(--accent-red); | |
| } | |
| .toast-icon { | |
| font-size: 1.25rem; | |
| flex-shrink: 0; | |
| } | |
| .toast-message { | |
| flex-grow: 1; | |
| } | |
| /* --- Layout: Dynamic Wrap --- */ | |
| .tool-layout { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 2rem; | |
| width: 100%; | |
| } | |
| .editor-side { | |
| flex: 2 1 600px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| min-width: 0; | |
| } | |
| .settings-side { | |
| flex: 1 1 400px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| min-width: 0; | |
| } | |
| /* --- Card Styling --- */ | |
| .card { | |
| background-color: var(--bg-card); | |
| border: 1px solid var(--border-color); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| backdrop-filter: var(--glass-blur); | |
| box-shadow: var(--shadow-md); | |
| transition: border-color var(--transition-fast); | |
| } | |
| .card:hover { | |
| border-color: rgba(99, 102, 241, 0.25); | |
| } | |
| .card-title { | |
| font-family: var(--font-heading); | |
| font-weight: 700; | |
| font-size: 1.2rem; | |
| margin-bottom: 1.25rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| border-bottom: 1px solid var(--border-color); | |
| padding-bottom: 0.75rem; | |
| } | |
| /* --- Painter Toolbar --- */ | |
| .toolbar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| padding: 0.75rem; | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid var(--border-color); | |
| border-radius: 12px; | |
| align-items: center; | |
| } | |
| .tool-btn { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 8px; | |
| border: 1px solid var(--border-color); | |
| background: transparent; | |
| color: var(--text-main); | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| } | |
| .tool-btn:hover:not(:disabled) { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-color: var(--text-muted); | |
| } | |
| .tool-btn.active { | |
| background: var(--primary); | |
| border-color: var(--primary); | |
| box-shadow: 0 0 10px var(--primary-glow); | |
| } | |
| .tool-btn:disabled { | |
| opacity: 0.35; | |
| cursor: not-allowed; | |
| } | |
| /* --- Grid Canvas Card --- */ | |
| .grid-container-card { | |
| background: rgba(0, 0, 0, 0.35); | |
| border: 1px solid var(--border-color); | |
| border-radius: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| height: 550px; | |
| min-height: 250px; | |
| min-width: 300px; | |
| max-width: 100%; | |
| position: relative; | |
| z-index: 2; | |
| resize: both; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-md); | |
| } | |
| .grid-wrapper { | |
| flex: 1; | |
| min-height: 0; | |
| overflow: auto; | |
| display: flex; | |
| padding: 1rem; | |
| background-image: radial-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 0); | |
| background-size: 16px 16px; | |
| } | |
| .grid-canvas { | |
| --cell-size: 32px; | |
| display: grid; | |
| gap: 1px; | |
| background-color: var(--border-color); | |
| border: 2px solid var(--border-color); | |
| border-radius: 6px; | |
| user-select: none; | |
| margin: auto; | |
| width: max-content; | |
| height: max-content; | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); | |
| } | |
| .grid-cell { | |
| width: var(--cell-size); | |
| height: var(--cell-size); | |
| background-color: #080a14; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| font-size: calc(var(--cell-size) * 0.35); | |
| font-weight: 700; | |
| cursor: pointer; | |
| transition: background-color var(--transition-fast), border var(--transition-fast); | |
| border: 1px solid transparent; | |
| } | |
| .grid-cell:hover { | |
| filter: brightness(1.25); | |
| border-color: var(--primary); | |
| } | |
| /* --- Tile Palette Colors (identical to collab-agent) --- */ | |
| .grid-cell.tile-empty { | |
| background-color: #080a14; | |
| color: rgba(255, 255, 255, 0.15); | |
| } | |
| .grid-cell.tile-normal { | |
| background-color: #1e293b; | |
| color: #94a3b8; | |
| } | |
| .grid-cell.tile-spawn { | |
| background-color: #059669; | |
| color: #ffffff; | |
| } | |
| .grid-cell.tile-deliver { | |
| background-color: #dc2626; | |
| color: #ffffff; | |
| } | |
| .grid-cell.tile-crate { | |
| background-color: #ca8a04; | |
| color: #0f172a; | |
| } | |
| .grid-cell.tile-crate_spawn { | |
| background-color: #ea580c; | |
| color: #ffffff; | |
| } | |
| .grid-cell.tile-arrow { | |
| background-color: #1e40af; | |
| color: #ffffff; | |
| font-size: calc(var(--cell-size) * 0.5); | |
| } | |
| /* --- Coordinate Tracker HUD --- */ | |
| .coord-overlay { | |
| display: flex; | |
| justify-content: space-between; | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid var(--border-color); | |
| border-radius: 10px; | |
| padding: 0.75rem 1rem; | |
| font-size: 0.85rem; | |
| backdrop-filter: var(--glass-blur); | |
| } | |
| .coord-item span { | |
| font-family: var(--font-mono); | |
| font-weight: 700; | |
| color: var(--accent-cyan); | |
| } | |
| /* --- Dynamic Form Groups --- */ | |
| .form-row { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .form-row .form-group { | |
| flex: 1; | |
| margin-bottom: 0; | |
| } | |
| .form-group { | |
| margin-bottom: 1.25rem; | |
| } | |
| label { | |
| display: block; | |
| font-size: 0.75rem; | |
| color: var(--text-muted); | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| margin-bottom: 0.4rem; | |
| letter-spacing: 0.05em; | |
| } | |
| .form-input { | |
| width: 100%; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| padding: 0.6rem 0.75rem; | |
| color: var(--text-main); | |
| font-family: inherit; | |
| font-size: 0.9rem; | |
| outline: none; | |
| transition: all var(--transition-fast); | |
| } | |
| .form-input:focus { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 8px var(--primary-glow); | |
| } | |
| .form-select { | |
| cursor: pointer; | |
| appearance: none; | |
| background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); | |
| background-position: right 0.5rem center; | |
| background-repeat: no-repeat; | |
| background-size: 1.25rem; | |
| padding-right: 2rem; | |
| } | |
| /* --- Buttons --- */ | |
| .btn { | |
| display: inline-flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 0.5rem; | |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 0.6rem 1rem; | |
| font-weight: 600; | |
| font-size: 0.88rem; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15); | |
| } | |
| .btn:hover { | |
| opacity: 0.95; | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 15px rgba(99, 102, 241, 0.25); | |
| } | |
| .btn-outline { | |
| background: transparent ; | |
| border: 1px solid var(--border-color); | |
| color: var(--text-main); | |
| box-shadow: none; | |
| } | |
| .btn-outline:hover { | |
| background: rgba(255, 255, 255, 0.04) ; | |
| border-color: var(--text-muted); | |
| } | |
| .btn-danger { | |
| background: linear-gradient(135deg, var(--accent-red), #b91c1c) ; | |
| box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15); | |
| } | |
| .btn-danger:hover { | |
| box-shadow: 0 4px 15px rgba(239, 68, 68, 0.25); | |
| } | |
| /* --- JSON Output Textarea --- */ | |
| .json-textarea-container { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .json-textarea { | |
| width: 100%; | |
| height: 350px; | |
| background: rgba(0, 0, 0, 0.4); | |
| border: 1px solid var(--border-color); | |
| border-radius: 12px; | |
| padding: 1rem; | |
| font-family: var(--font-mono); | |
| font-size: 0.85rem; | |
| color: #818cf8; | |
| outline: none; | |
| resize: vertical; | |
| line-height: 1.5; | |
| transition: all var(--transition-fast); | |
| } | |
| .json-textarea:focus { | |
| border-color: var(--primary); | |
| background: rgba(0, 0, 0, 0.5); | |
| box-shadow: 0 0 10px rgba(99, 102, 241, 0.1); | |
| } | |
| /* --- Validation Status Badges --- */ | |
| .validation-status { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| margin-bottom: 0.75rem; | |
| border: 1px solid transparent; | |
| } | |
| .validation-valid { | |
| background-color: rgba(16, 185, 129, 0.1); | |
| color: var(--accent-green); | |
| border-color: rgba(16, 185, 129, 0.2); | |
| } | |
| .validation-invalid { | |
| background-color: rgba(239, 68, 68, 0.1); | |
| color: var(--accent-red); | |
| border-color: rgba(239, 68, 68, 0.2); | |
| font-family: var(--font-mono); | |
| font-size: 0.75rem; | |
| white-space: pre-wrap; | |
| } | |
| /* --- Dynamic NPC list --- */ | |
| .npc-list-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| .npc-row { | |
| display: flex; | |
| gap: 0.5rem; | |
| align-items: center; | |
| } | |
| .npc-row>* { | |
| flex-shrink: 1; | |
| min-width: 0; | |
| } | |
| /* --- Keybind Helper Box --- */ | |
| .shortcuts-box { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); | |
| gap: 0.75rem; | |
| padding: 1rem; | |
| background: rgba(255, 255, 255, 0.01); | |
| border: 1px solid var(--border-color); | |
| border-radius: 12px; | |
| font-size: 0.78rem; | |
| } | |
| .shortcut-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| color: var(--text-muted); | |
| } | |
| .shortcut-key { | |
| font-family: var(--font-mono); | |
| background: rgba(255, 255, 255, 0.08); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| color: var(--text-main); | |
| padding: 0.1rem 0.35rem; | |
| border-radius: 4px; | |
| font-weight: 700; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Dynamic glows background --> | |
| <div class="bg-glows"> | |
| <div class="glow-1"></div> | |
| <div class="glow-2"></div> | |
| </div> | |
| <div class="app-container"> | |
| <header> | |
| <h1>Map Builder</h1> | |
| <div class="subtitle">Visually paint map grids, configure NPCS, and directly edit/validate the game | |
| configuration JSON.</div> | |
| </header> | |
| <div class="tool-layout"> | |
| <!-- Left Column: Visual Editor --> | |
| <div class="editor-side"> | |
| <!-- Dimension sizing & cell zoom controls --> | |
| <div class="card"> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="gridWidth">Grid Width (Columns)</label> | |
| <input type="number" id="gridWidth" class="form-input" value="10" min="1" max="50" | |
| onchange="resizeGrid()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="gridHeight">Grid Height (Rows)</label> | |
| <input type="number" id="gridHeight" class="form-input" value="10" min="1" max="50" | |
| onchange="resizeGrid()"> | |
| </div> | |
| </div> | |
| <div class="form-row" | |
| style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 1rem; align-items: flex-end;"> | |
| <div class="form-group" style="flex: 2; margin-bottom: 0;"> | |
| <label for="cellSize">Cell Zoom Slider</label> | |
| <input type="range" id="cellSize" class="form-input" min="8" max="128" value="32" | |
| oninput="updateZoom(this.value)"> | |
| </div> | |
| <div class="form-group" style="flex: 1; margin-bottom: 0;"> | |
| <label for="cellSizeNum">Size (px)</label> | |
| <input type="number" id="cellSizeNum" class="form-input" min="8" value="32" | |
| oninput="updateZoom(this.value)"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Paint Palette Toolbar --> | |
| <div class="toolbar"> | |
| <button class="tool-btn btn-outline" id="undoBtn" onclick="undo()" title="Undo (Ctrl+Z)" disabled>↩️ | |
| Undo</button> | |
| <button class="tool-btn btn-outline" id="redoBtn" onclick="redo()" title="Redo (Ctrl+Y)" disabled>↪️ | |
| Redo</button> | |
| <div style="width: 1px; height: 24px; background: rgba(255,255,255,0.1); margin: 0 0.25rem;"></div> | |
| <button class="tool-btn active" data-tile="empty" onclick="selectTool(this)" title="[0] or [e]">🧱 | |
| Wall (0)</button> | |
| <button class="tool-btn" data-tile="spawn" onclick="selectTool(this)" title="[1] or [s]">🟢 Spawn | |
| (1)</button> | |
| <button class="tool-btn" data-tile="deliver" onclick="selectTool(this)" title="[2] or [d]">🔴 | |
| Delivery (2)</button> | |
| <button class="tool-btn" data-tile="normal" onclick="selectTool(this)" title="[3] or [w]">⬜ | |
| Pavement (3)</button> | |
| <button class="tool-btn" data-tile="crate" onclick="selectTool(this)" title="[5] or [c]">📦 Crate | |
| (5)</button> | |
| <button class="tool-btn" data-tile="crate_spawn" onclick="selectTool(this)" title="[!] or [x]">🎯 | |
| Crate Spawn (5!)</button> | |
| <button class="tool-btn" data-tile="↑" onclick="selectTool(this)" title="[Up Arrow] ↑">⬆️ Up | |
| Arrow</button> | |
| <button class="tool-btn" data-tile="→" onclick="selectTool(this)" title="[Right Arrow] →">➡️ Right | |
| Arrow</button> | |
| <button class="tool-btn" data-tile="↓" onclick="selectTool(this)" title="[Down Arrow] ↓">⬇️ Down | |
| Arrow</button> | |
| <button class="tool-btn" data-tile="←" onclick="selectTool(this)" title="[Left Arrow] ←">⬅️ Left | |
| Arrow</button> | |
| </div> | |
| <!-- Grid Canvas Viewport --> | |
| <div class="grid-container-card" id="gridContainerCard"> | |
| <div class="grid-wrapper" id="gridWrapper"> | |
| <div class="grid-canvas" id="gridCanvas"> | |
| <!-- Injected dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Coordinates HUD --> | |
| <div class="coord-overlay"> | |
| <div class="coord-item">Visual Coordinate: <span id="visualCoord">Row: 0, Col: 0</span></div> | |
| <div class="coord-item">JSON Grid Coordinate: <span id="schemaCoord">X: 0, Y: 9</span></div> | |
| </div> | |
| <!-- Keyboard shortcuts cheatsheet --> | |
| <div class="card"> | |
| <div class="card-title" | |
| style="font-size: 1rem; border-bottom: none; margin-bottom: 0.75rem; padding-bottom: 0;">⌨️ | |
| Keyboard Shortcuts</div> | |
| <div class="shortcuts-box"> | |
| <div class="shortcut-item"><span class="shortcut-key">0</span> / <span | |
| class="shortcut-key">e</span> <span>Wall</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">1</span> / <span | |
| class="shortcut-key">s</span> <span>Spawn</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">2</span> / <span | |
| class="shortcut-key">d</span> <span>Delivery</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">3</span> / <span | |
| class="shortcut-key">w</span> <span>Pavement</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">5</span> / <span | |
| class="shortcut-key">c</span> <span>Crate</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">!</span> / <span | |
| class="shortcut-key">x</span> <span>Crate Spawn</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">↑ ↓ ← →</span> <span>Arrows</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">Ctrl+Z</span> <span>Undo</span></div> | |
| <div class="shortcut-item"><span class="shortcut-key">Ctrl+Y</span> <span>Redo</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Column: Settings & JSON Code Editor --> | |
| <div class="settings-side"> | |
| <!-- Game Settings Card --> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <span>⚙️</span> Game Settings | |
| </div> | |
| <div class="form-group"> | |
| <label for="mapTitle">Map Title</label> | |
| <input type="text" id="mapTitle" class="form-input" value="custom_arena" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mapDesc">Map Description</label> | |
| <textarea id="mapDesc" class="form-input" style="height: 60px; resize: vertical;" | |
| oninput="generateJson()">Visually created map matching Deliveroo column-oriented coordination grid.</textarea> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="maxPlayers">Max Players</label> | |
| <input type="number" id="maxPlayers" class="form-input" value="4" min="1" max="100" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="capacity">Player Cargo Capacity</label> | |
| <input type="number" id="capacity" class="form-input" value="5" min="1" max="100" | |
| oninput="generateJson()"> | |
| </div> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="parcelMax">Max Parcels On Map</label> | |
| <input type="number" id="parcelMax" class="form-input" value="5" min="0" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="parcelGen">Parcel Spawn Rate</label> | |
| <select id="parcelGen" class="form-input form-select" onchange="generateJson()"> | |
| <option value="1s">1s</option> | |
| <option value="2s" selected>2s</option> | |
| <option value="5s">5s</option> | |
| <option value="10s">10s</option> | |
| <option value="infinite">infinite</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="movementDuration">Agent Move Duration (ms)</label> | |
| <input type="number" id="movementDuration" class="form-input" value="50" min="0" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="obsDistance">Observation Distance</label> | |
| <input type="number" id="obsDistance" class="form-input" value="5" min="0" | |
| oninput="generateJson()"> | |
| </div> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="agentType">Agent Type</label> | |
| <input type="text" id="agentType" class="form-input" value="" placeholder="default (undefined)" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="parcelDecay">Parcel Decay Rate</label> | |
| <input type="text" id="parcelDecay" class="form-input" value="1s" placeholder="e.g. 1s" | |
| oninput="generateJson()"> | |
| </div> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="rewardAvg">Parcel Reward Avg</label> | |
| <input type="number" id="rewardAvg" class="form-input" value="30" min="0" | |
| oninput="generateJson()"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="rewardVariance">Parcel Reward Var</label> | |
| <input type="number" id="rewardVariance" class="form-input" value="10" min="0" | |
| oninput="generateJson()"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- NPC Configuration Card --> | |
| <div class="card"> | |
| <div class="card-title" style="justify-content: space-between; display: flex; align-items: center;"> | |
| <span>🤖 NPCs Configuration</span> | |
| <button class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.75rem;" onclick="addNpcRow()">➕ | |
| Add NPC</button> | |
| </div> | |
| <div class="npc-list-container" id="npcList"> | |
| <!-- Dynamic NPC list --> | |
| </div> | |
| </div> | |
| <!-- Editable Config JSON Card --> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <span>📄 Game Configuration JSON</span> | |
| </div> | |
| <!-- Validation output banner --> | |
| <div id="jsonValidationStatus" class="validation-status validation-valid"> | |
| ✔️ Valid Configuration JSON | |
| </div> | |
| <div class="json-textarea-container"> | |
| <textarea id="jsonOutput" class="json-textarea" oninput="handleJsonTextareaInput()"></textarea> | |
| </div> | |
| <div style="margin-top: 1.25rem; display: flex; gap: 0.75rem; flex-wrap: wrap;"> | |
| <button class="btn" style="flex: 1; min-width: 90px;" onclick="copyJson()">📋 Copy</button> | |
| <button class="btn btn-outline" style="flex: 1; min-width: 90px;" onclick="downloadJson()">💾 | |
| Download</button> | |
| <button class="btn btn-outline" style="flex: 1; min-width: 90px;" onclick="toggleImport()">📥 | |
| Import</button> | |
| </div> | |
| <div id="importContainer" | |
| style="display: none; margin-top: 1.25rem; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 1.25rem;"> | |
| <label for="importJsonInput">Paste Map JSON or Choose File</label> | |
| <input type="file" id="mapFileSelect" accept=".json" class="form-input" | |
| style="margin-bottom: 0.75rem;" onchange="handleMapFileSelect(this, 'importJsonInput')"> | |
| <textarea id="importJsonInput" class="form-input" | |
| style="font-family: var(--font-mono); font-size: 0.8rem; height: 120px; resize: vertical;" | |
| placeholder='{ "map": { "tiles": [...] } }'></textarea> | |
| <button class="btn" style="margin-top: 0.75rem;" onclick="applyImport()">Apply Import</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer | |
| style="margin-top: 3rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem; text-align: center; font-size: 0.72rem; color: var(--text-muted); margin-bottom: 2rem;"> | |
| made by <a href="https://github.com/ale-bena" target="_blank" | |
| style="color: var(--text-main); font-weight: 700; text-decoration: none; transition: color var(--transition-fast);" | |
| onmouseover="this.style.color='var(--primary)'" | |
| onmouseout="this.style.color='var(--text-main)'">Alessandro Benassi</a> and <a | |
| href="https://github.com/xupremix" target="_blank" | |
| style="color: var(--text-main); font-weight: 700; text-decoration: none; transition: color var(--transition-fast);" | |
| onmouseover="this.style.color='var(--primary)'" onmouseout="this.style.color='var(--text-main)'">Lollato | |
| Filippo</a> | |
| </footer> | |
| </div> | |
| <script> | |
| let gridWidth = 10; | |
| let gridHeight = 10; | |
| let activeTool = "empty"; | |
| let gridState = []; // 2D array representation of the visual grid: gridState[row][col] | |
| let isMouseDown = false; | |
| let undoStack = []; | |
| let redoStack = []; | |
| let skipJsonOutputUpdate = false; | |
| // NPC State array: each entry is { type: "random", count: 1, moving_event: "frame" } | |
| let npcState = [ | |
| { type: "random", count: 1, moving_event: "frame" } | |
| ]; | |
| const displaySymbols = { | |
| "empty": "0", | |
| "normal": "3", | |
| "spawn": "1", | |
| "deliver": "2", | |
| "crate_spawn": "5!", | |
| "crate": "5" | |
| }; | |
| const reverseSymbols = { | |
| "0": "empty", | |
| "3": "normal", | |
| "1": "spawn", | |
| "2": "deliver", | |
| "5": "crate", | |
| "5!": "crate_spawn" | |
| }; | |
| // Hotkeys binding listeners | |
| document.addEventListener('keydown', (e) => { | |
| // Bypass hotkeys when focused on form inputs or textarea | |
| const tag = document.activeElement.tagName; | |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { | |
| return; | |
| } | |
| if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { | |
| e.preventDefault(); | |
| undo(); | |
| } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { | |
| e.preventDefault(); | |
| redo(); | |
| } else if (e.key === '0' || e.key === 'e') { | |
| selectToolByAttribute('empty'); | |
| } else if (e.key === '3' || e.key === 'w') { | |
| selectToolByAttribute('normal'); | |
| } else if (e.key === '1' || e.key === 's') { | |
| selectToolByAttribute('spawn'); | |
| } else if (e.key === '2' || e.key === 'd') { | |
| selectToolByAttribute('deliver'); | |
| } else if (e.key === '5' || e.key === 'c') { | |
| selectToolByAttribute('crate'); | |
| } else if (e.key === '!' || e.key === 'x') { | |
| selectToolByAttribute('crate_spawn'); | |
| } else if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| selectToolByAttribute('↑'); | |
| } else if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| selectToolByAttribute('→'); | |
| } else if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| selectToolByAttribute('↓'); | |
| } else if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| selectToolByAttribute('←'); | |
| } | |
| }); | |
| function selectToolByAttribute(val) { | |
| const btn = document.querySelector(`.tool-btn[data-tile="${val}"]`); | |
| if (btn) { | |
| selectTool(btn); | |
| } | |
| } | |
| function saveState() { | |
| const stateCopy = JSON.parse(JSON.stringify(gridState)); | |
| undoStack.push(stateCopy); | |
| if (undoStack.length > 50) { | |
| undoStack.shift(); | |
| } | |
| redoStack = []; | |
| updateHistoryButtons(); | |
| } | |
| function undo() { | |
| if (undoStack.length === 0) return; | |
| const currentState = JSON.parse(JSON.stringify(gridState)); | |
| redoStack.push(currentState); | |
| gridState = undoStack.pop(); | |
| renderCanvas(); | |
| generateJson(); | |
| updateHistoryButtons(); | |
| } | |
| function redo() { | |
| if (redoStack.length === 0) return; | |
| const currentState = JSON.parse(JSON.stringify(gridState)); | |
| undoStack.push(currentState); | |
| gridState = redoStack.pop(); | |
| renderCanvas(); | |
| generateJson(); | |
| updateHistoryButtons(); | |
| } | |
| function updateHistoryButtons() { | |
| const undoBtn = document.getElementById('undoBtn'); | |
| const redoBtn = document.getElementById('redoBtn'); | |
| if (undoBtn) undoBtn.disabled = (undoStack.length === 0); | |
| if (redoBtn) redoBtn.disabled = (redoStack.length === 0); | |
| } | |
| function initGrid() { | |
| gridState = []; | |
| for (let r = 0; r < gridHeight; r++) { | |
| const row = []; | |
| for (let c = 0; c < gridWidth; c++) { | |
| row.push("normal"); | |
| } | |
| gridState.push(row); | |
| } | |
| // Default spawn and delivery cells | |
| if (gridHeight >= 3 && gridWidth >= 3) { | |
| gridState[1][1] = "spawn"; | |
| gridState[gridHeight - 2][gridWidth - 2] = "deliver"; | |
| } else { | |
| if (gridHeight > 0 && gridWidth > 0) { | |
| gridState[0][0] = "spawn"; | |
| } | |
| if (gridHeight > 1 || gridWidth > 1) { | |
| gridState[gridHeight - 1][gridWidth - 1] = "deliver"; | |
| } | |
| } | |
| renderCanvas(); | |
| renderNpcList(); | |
| } | |
| function selectTool(btn) { | |
| document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| activeTool = btn.getAttribute('data-tile'); | |
| } | |
| function resizeGrid() { | |
| const newW = parseInt(document.getElementById('gridWidth').value) || 10; | |
| const newH = parseInt(document.getElementById('gridHeight').value) || 10; | |
| const clampedW = Math.max(1, Math.min(50, newW)); | |
| const clampedH = Math.max(1, Math.min(50, newH)); | |
| document.getElementById('gridWidth').value = clampedW; | |
| document.getElementById('gridHeight').value = clampedH; | |
| // Preserve old gridState contents where overlapping | |
| const oldState = gridState; | |
| const oldH = gridHeight; | |
| const oldW = gridWidth; | |
| gridWidth = clampedW; | |
| gridHeight = clampedH; | |
| gridState = []; | |
| for (let r = 0; r < gridHeight; r++) { | |
| const row = []; | |
| for (let c = 0; c < gridWidth; c++) { | |
| if (r < oldH && c < oldW) { | |
| row.push(oldState[r][c]); | |
| } else { | |
| row.push("normal"); | |
| } | |
| } | |
| gridState.push(row); | |
| } | |
| renderCanvas(); | |
| } | |
| function updateZoom(val) { | |
| let parsed = parseInt(val) || 32; | |
| if (parsed < 8) parsed = 8; | |
| document.getElementById('cellSize').value = Math.min(parsed, 128); | |
| document.getElementById('cellSizeNum').value = parsed; | |
| const canvas = document.getElementById('gridCanvas'); | |
| canvas.style.setProperty('--cell-size', `${parsed}px`); | |
| adjustFlexBasis(); | |
| } | |
| function adjustFlexBasis() { | |
| const cellSize = parseInt(document.getElementById('cellSizeNum').value) || 32; | |
| const canvasWidth = gridWidth * cellSize + (gridWidth - 1) + 4 + 48; // cols * size + gaps + borders + padding | |
| const editorSide = document.querySelector('.editor-side'); | |
| if (editorSide) { | |
| if (canvasWidth > 600) { | |
| editorSide.style.flexBasis = `${Math.min(canvasWidth, 1400)}px`; | |
| } else { | |
| editorSide.style.flexBasis = '600px'; | |
| } | |
| } | |
| } | |
| function handleCellAction(row, col) { | |
| gridState[row][col] = activeTool; | |
| updateCellClass(row, col); | |
| generateJson(); | |
| } | |
| function updateCellClass(row, col) { | |
| const cell = document.getElementById(`cell-${row}-${col}`); | |
| if (!cell) return; | |
| cell.className = 'grid-cell'; | |
| const value = gridState[row][col]; | |
| if (['↑', '→', '↓', '←'].includes(value)) { | |
| cell.classList.add('tile-arrow'); | |
| cell.innerText = value; | |
| } else { | |
| cell.classList.add(`tile-${value}`); | |
| cell.innerText = displaySymbols[value] !== undefined ? displaySymbols[value] : value; | |
| } | |
| } | |
| function handleHover(row, col) { | |
| const schemaX = col; | |
| const schemaY = gridHeight - 1 - row; | |
| document.getElementById('visualCoord').innerText = `Row: ${row}, Col: ${col}`; | |
| document.getElementById('schemaCoord').innerText = `X: ${schemaX}, Y: ${schemaY}`; | |
| } | |
| function renderCanvas() { | |
| const canvas = document.getElementById('gridCanvas'); | |
| const zoomVal = document.getElementById('cellSizeNum').value; | |
| canvas.style.setProperty('--cell-size', `${zoomVal}px`); | |
| canvas.style.gridTemplateColumns = `repeat(${gridWidth}, var(--cell-size))`; | |
| canvas.innerHTML = ''; | |
| for (let r = 0; r < gridHeight; r++) { | |
| for (let c = 0; c < gridWidth; c++) { | |
| const cell = document.createElement('div'); | |
| cell.id = `cell-${r}-${c}`; | |
| cell.className = 'grid-cell'; | |
| cell.addEventListener('mousedown', (e) => { | |
| saveState(); | |
| isMouseDown = true; | |
| handleCellAction(r, c); | |
| }); | |
| cell.addEventListener('mouseover', () => { | |
| handleHover(r, c); | |
| if (isMouseDown) { | |
| handleCellAction(r, c); | |
| } | |
| }); | |
| canvas.appendChild(cell); | |
| updateCellClass(r, c); | |
| } | |
| } | |
| document.addEventListener('mouseup', () => { | |
| isMouseDown = false; | |
| }); | |
| generateJson(); | |
| adjustFlexBasis(); | |
| } | |
| // --- NPC Dynamic List Form --- | |
| function renderNpcList() { | |
| const container = document.getElementById('npcList'); | |
| container.innerHTML = ''; | |
| if (npcState.length === 0) { | |
| container.innerHTML = `<div style="font-size:0.8rem; color:var(--text-muted); text-align:center; padding:0.5rem;">No NPCs configured.</div>`; | |
| return; | |
| } | |
| npcState.forEach((npc, index) => { | |
| const row = document.createElement('div'); | |
| row.className = 'npc-row'; | |
| row.innerHTML = ` | |
| <input type="text" class="form-input" style="flex: 2;" placeholder="Type (e.g. random)" value="${npc.type || ''}" oninput="updateNpc(${index}, 'type', this.value)"> | |
| <input type="number" class="form-input" style="flex: 1.2;" placeholder="Count" min="0" value="${npc.count || 0}" oninput="updateNpc(${index}, 'count', this.value)"> | |
| <input type="text" class="form-input" style="flex: 2;" placeholder="Event (e.g. frame)" value="${npc.moving_event || ''}" oninput="updateNpc(${index}, 'moving_event', this.value)"> | |
| <button class="btn btn-outline btn-danger" style="flex-shrink: 0; padding: 0.5rem 0.6rem;" onclick="removeNpcRow(${index})" title="Delete NPC Group">🗑️</button> | |
| `; | |
| container.appendChild(row); | |
| }); | |
| } | |
| function addNpcRow() { | |
| npcState.push({ type: 'random', count: 1, moving_event: 'frame' }); | |
| renderNpcList(); | |
| generateJson(); | |
| } | |
| function removeNpcRow(index) { | |
| npcState.splice(index, 1); | |
| renderNpcList(); | |
| generateJson(); | |
| } | |
| function updateNpc(index, field, value) { | |
| if (field === 'count') { | |
| npcState[index][field] = parseInt(value) || 0; | |
| } else { | |
| npcState[index][field] = value; | |
| } | |
| generateJson(); | |
| } | |
| // --- JSON Generation --- | |
| function generateJson() { | |
| const title = document.getElementById('mapTitle').value || 'custom_arena'; | |
| const description = document.getElementById('mapDesc').value || 'Visually created map matching Deliveroo column-oriented coordination grid.'; | |
| const maxPlayers = parseInt(document.getElementById('maxPlayers').value) || 4; | |
| const capacity = parseInt(document.getElementById('capacity').value) || 5; | |
| const parcelMax = parseInt(document.getElementById('parcelMax').value) || 5; | |
| const parcelGen = document.getElementById('parcelGen').value || '2s'; | |
| // Custom configuration fields | |
| const movementDurationVal = document.getElementById('movementDuration').value; | |
| const movementDuration = movementDurationVal !== "" ? parseInt(movementDurationVal) : 50; | |
| const obsDistanceVal = document.getElementById('obsDistance').value; | |
| const obsDistance = obsDistanceVal !== "" ? parseInt(obsDistanceVal) : 5; | |
| const agentTypeInput = document.getElementById('agentType').value.trim(); | |
| const agentType = agentTypeInput === "" ? undefined : agentTypeInput; | |
| const parcelDecay = document.getElementById('parcelDecay').value || '1s'; | |
| const rewardAvgVal = document.getElementById('rewardAvg').value; | |
| const rewardAvg = rewardAvgVal !== "" ? parseInt(rewardAvgVal) : 30; | |
| const rewardVarianceVal = document.getElementById('rewardVariance').value; | |
| const rewardVariance = rewardVarianceVal !== "" ? parseInt(rewardVarianceVal) : 10; | |
| const tiles = []; | |
| for (let x = 0; x < gridWidth; x++) { | |
| const column = []; | |
| for (let y = 0; y < gridHeight; y++) { | |
| const visualRow = gridHeight - 1 - y; | |
| const visualCol = x; | |
| const val = gridState[visualRow][visualCol]; | |
| column.push(displaySymbols[val] !== undefined ? displaySymbols[val] : val); | |
| } | |
| tiles.push(column); | |
| } | |
| const formattedNpcs = npcState.map(npc => { | |
| const entry = { type: npc.type || 'random', count: npc.count || 0 }; | |
| if (npc.moving_event) { | |
| entry.moving_event = npc.moving_event; | |
| } | |
| return entry; | |
| }); | |
| const payload = { | |
| title: title, | |
| description: description, | |
| maxPlayers: maxPlayers, | |
| map: { | |
| width: gridWidth, | |
| height: gridHeight, | |
| tiles: tiles | |
| }, | |
| npcs: formattedNpcs, | |
| parcels: { | |
| generation_event: parcelGen, | |
| decaying_event: parcelDecay, | |
| max: parcelMax, | |
| reward_avg: rewardAvg, | |
| reward_variance: rewardVariance | |
| }, | |
| player: { | |
| agent_type: agentType, | |
| movement_duration: movementDuration, | |
| observation_distance: obsDistance, | |
| capacity: capacity | |
| } | |
| }; | |
| if (!skipJsonOutputUpdate) { | |
| const textarea = document.getElementById('jsonOutput'); | |
| textarea.value = JSON.stringify(payload, null, 2); | |
| } | |
| setValidationStatus(true); | |
| } | |
| // --- Bi-directional JSON Input Parsing & Validation --- | |
| function handleJsonTextareaInput() { | |
| const textarea = document.getElementById('jsonOutput'); | |
| const raw = textarea.value.trim(); | |
| if (!raw) { | |
| setValidationStatus(false, "Textarea is empty."); | |
| return; | |
| } | |
| try { | |
| const data = JSON.parse(raw); | |
| // Run basic schema checking | |
| if (!data.map || typeof data.map.width === 'undefined' || typeof data.map.height === 'undefined' || !data.map.tiles) { | |
| setValidationStatus(false, "Schema validation failed:\nMissing 'map' fields (width, height, or tiles)."); | |
| return; | |
| } | |
| const width = parseInt(data.map.width); | |
| const height = parseInt(data.map.height); | |
| if (isNaN(width) || isNaN(height) || width < 1 || height < 1 || width > 50 || height > 50) { | |
| setValidationStatus(false, `Schema validation failed:\nGrid dimensions must be integers between 1 and 50 (got width=${width}, height=${height}).`); | |
| return; | |
| } | |
| const tiles = data.map.tiles; | |
| if (!Array.isArray(tiles) || tiles.length !== width) { | |
| setValidationStatus(false, `Schema validation failed:\n'map.tiles' must be an array representing columns. Length must equal width (${width}), got ${Array.isArray(tiles) ? tiles.length : typeof tiles}.`); | |
| return; | |
| } | |
| for (let x = 0; x < width; x++) { | |
| if (!Array.isArray(tiles[x]) || tiles[x].length !== height) { | |
| setValidationStatus(false, `Schema validation failed:\nColumn tiles[${x}] must be an array of length height (${height}), got ${Array.isArray(tiles[x]) ? tiles[x].length : typeof tiles[x]}.`); | |
| return; | |
| } | |
| } | |
| // If valid, sync settings and canvas state | |
| saveState(); | |
| // Set flag to avoid overwriting textarea while typing | |
| skipJsonOutputUpdate = true; | |
| gridWidth = width; | |
| gridHeight = height; | |
| document.getElementById('gridWidth').value = gridWidth; | |
| document.getElementById('gridHeight').value = gridHeight; | |
| if (data.title) document.getElementById('mapTitle').value = data.title; | |
| if (data.description) document.getElementById('mapDesc').value = data.description; | |
| if (data.maxPlayers) document.getElementById('maxPlayers').value = data.maxPlayers; | |
| if (data.player && data.player.capacity) document.getElementById('capacity').value = data.player.capacity; | |
| if (data.parcels && data.parcels.max) document.getElementById('parcelMax').value = data.parcels.max; | |
| // Sync new configuration fields | |
| if (data.player && data.player.movement_duration !== undefined) { | |
| document.getElementById('movementDuration').value = data.player.movement_duration; | |
| } | |
| if (data.player && data.player.observation_distance !== undefined) { | |
| document.getElementById('obsDistance').value = data.player.observation_distance; | |
| } | |
| if (data.player && data.player.agent_type !== undefined) { | |
| document.getElementById('agentType').value = data.player.agent_type; | |
| } else { | |
| document.getElementById('agentType').value = ''; | |
| } | |
| if (data.parcels && data.parcels.decaying_event !== undefined) { | |
| document.getElementById('parcelDecay').value = data.parcels.decaying_event; | |
| } | |
| if (data.parcels && data.parcels.reward_avg !== undefined) { | |
| document.getElementById('rewardAvg').value = data.parcels.reward_avg; | |
| } | |
| if (data.parcels && data.parcels.reward_variance !== undefined) { | |
| document.getElementById('rewardVariance').value = data.parcels.reward_variance; | |
| } | |
| // Sync parcel spawn rate select dropdown | |
| if (data.parcels && data.parcels.generation_event) { | |
| const genEvent = String(data.parcels.generation_event).trim().toLowerCase(); | |
| const dropdown = document.getElementById('parcelGen'); | |
| if (['1s', '2s', '5s', '10s', 'infinite'].includes(genEvent)) { | |
| dropdown.value = genEvent; | |
| } else { | |
| dropdown.value = '2s'; | |
| } | |
| } | |
| // Sync NPCs | |
| if (Array.isArray(data.npcs)) { | |
| npcState = data.npcs.map(n => ({ | |
| type: n.type || 'random', | |
| count: parseInt(n.count) || 0, | |
| moving_event: n.moving_event || '' | |
| })); | |
| } else { | |
| npcState = []; | |
| } | |
| renderNpcList(); | |
| // Re-build gridState from column tiles | |
| gridState = []; | |
| for (let r = 0; r < gridHeight; r++) { | |
| const row = []; | |
| for (let c = 0; c < gridWidth; c++) { | |
| row.push("normal"); | |
| } | |
| gridState.push(row); | |
| } | |
| for (let x = 0; x < gridWidth; x++) { | |
| for (let y = 0; y < gridHeight; y++) { | |
| const visualRow = gridHeight - 1 - y; | |
| const visualCol = x; | |
| const rawVal = String(tiles[x][y]); | |
| gridState[visualRow][visualCol] = reverseSymbols[rawVal] || rawVal || "normal"; | |
| } | |
| } | |
| renderCanvas(); | |
| setValidationStatus(true); | |
| } catch (err) { | |
| setValidationStatus(false, `JSON Syntax Error:\n${err.message}`); | |
| } finally { | |
| skipJsonOutputUpdate = false; | |
| } | |
| } | |
| function setValidationStatus(isValid, errorMessage = "") { | |
| const banner = document.getElementById('jsonValidationStatus'); | |
| if (isValid) { | |
| banner.className = "validation-status validation-valid"; | |
| banner.innerText = "✔️ Valid Configuration JSON"; | |
| } else { | |
| banner.className = "validation-status validation-invalid"; | |
| banner.innerText = errorMessage; | |
| } | |
| } | |
| // --- Actions --- | |
| function downloadJson() { | |
| const text = document.getElementById('jsonOutput').value; | |
| const title = document.getElementById('mapTitle').value || 'custom_arena'; | |
| const blob = new Blob([text], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${title}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function copyJson() { | |
| const text = document.getElementById('jsonOutput').value; | |
| navigator.clipboard.writeText(text).then(() => { | |
| showNotification("Game Configuration JSON copied to clipboard!", "success"); | |
| }); | |
| } | |
| // --- Import Helper Functions --- | |
| function toggleImport() { | |
| const container = document.getElementById('importContainer'); | |
| if (container.style.display === 'none') { | |
| container.style.display = 'block'; | |
| } else { | |
| container.style.display = 'none'; | |
| } | |
| } | |
| function handleMapFileSelect(input, textareaId) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| document.getElementById(textareaId).value = e.target.result; | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function applyImport() { | |
| const rawJson = document.getElementById('importJsonInput').value.trim(); | |
| if (!rawJson) return; | |
| try { | |
| const data = JSON.parse(rawJson); | |
| if (!data.map || typeof data.map.width === 'undefined' || typeof data.map.height === 'undefined' || !data.map.tiles) { | |
| showNotification("Invalid format! Must contain map.width, map.height, and map.tiles.", "error"); | |
| return; | |
| } | |
| saveState(); | |
| gridWidth = data.map.width; | |
| gridHeight = data.map.height; | |
| document.getElementById('gridWidth').value = gridWidth; | |
| document.getElementById('gridHeight').value = gridHeight; | |
| if (data.title) document.getElementById('mapTitle').value = data.title; | |
| if (data.description) document.getElementById('mapDesc').value = data.description; | |
| if (data.maxPlayers) document.getElementById('maxPlayers').value = data.maxPlayers; | |
| if (data.player && data.player.capacity) document.getElementById('capacity').value = data.player.capacity; | |
| if (data.parcels && data.parcels.max) document.getElementById('parcelMax').value = data.parcels.max; | |
| // Sync new configuration fields | |
| if (data.player && data.player.movement_duration !== undefined) { | |
| document.getElementById('movementDuration').value = data.player.movement_duration; | |
| } | |
| if (data.player && data.player.observation_distance !== undefined) { | |
| document.getElementById('obsDistance').value = data.player.observation_distance; | |
| } | |
| if (data.player && data.player.agent_type !== undefined) { | |
| document.getElementById('agentType').value = data.player.agent_type; | |
| } else { | |
| document.getElementById('agentType').value = ''; | |
| } | |
| if (data.parcels && data.parcels.decaying_event !== undefined) { | |
| document.getElementById('parcelDecay').value = data.parcels.decaying_event; | |
| } | |
| if (data.parcels && data.parcels.reward_avg !== undefined) { | |
| document.getElementById('rewardAvg').value = data.parcels.reward_avg; | |
| } | |
| if (data.parcels && data.parcels.reward_variance !== undefined) { | |
| document.getElementById('rewardVariance').value = data.parcels.reward_variance; | |
| } | |
| if (data.parcels && data.parcels.generation_event) { | |
| const genEvent = String(data.parcels.generation_event).trim().toLowerCase(); | |
| const dropdown = document.getElementById('parcelGen'); | |
| if (['1s', '2s', '5s', '10s', 'infinite'].includes(genEvent)) { | |
| dropdown.value = genEvent; | |
| } else { | |
| dropdown.value = '2s'; | |
| } | |
| } | |
| if (Array.isArray(data.npcs)) { | |
| npcState = data.npcs.map(n => ({ | |
| type: n.type || 'random', | |
| count: parseInt(n.count) || 0, | |
| moving_event: n.moving_event || '' | |
| })); | |
| } else { | |
| npcState = []; | |
| } | |
| renderNpcList(); | |
| gridState = []; | |
| for (let r = 0; r < gridHeight; r++) { | |
| const row = []; | |
| for (let c = 0; c < gridWidth; c++) { | |
| row.push("normal"); | |
| } | |
| gridState.push(row); | |
| } | |
| const localReverseSymbols = { | |
| "0": "empty", | |
| "3": "normal", | |
| "1": "spawn", | |
| "2": "deliver", | |
| "5": "crate", | |
| "5!": "crate_spawn" | |
| }; | |
| for (let x = 0; x < gridWidth; x++) { | |
| for (let y = 0; y < gridHeight; y++) { | |
| const visualRow = gridHeight - 1 - y; | |
| const visualCol = x; | |
| const rawVal = String(data.map.tiles[x][y]); | |
| gridState[visualRow][visualCol] = localReverseSymbols[rawVal] || rawVal || "normal"; | |
| } | |
| } | |
| renderCanvas(); | |
| document.getElementById('importContainer').style.display = 'none'; | |
| document.getElementById('importJsonInput').value = ''; | |
| showNotification("Map imported successfully!", "success"); | |
| } catch (err) { | |
| showNotification("JSON parsing error: " + err.message, "error"); | |
| } | |
| } | |
| function showNotification(message, type = 'success') { | |
| let container = document.getElementById('toast-container'); | |
| if (!container) { | |
| container = document.createElement('div'); | |
| container.id = 'toast-container'; | |
| container.className = 'toast-container'; | |
| document.body.appendChild(container); | |
| } | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| let icon = '✨'; | |
| if (type === 'success') icon = '✅'; | |
| if (type === 'error') icon = '❌'; | |
| toast.innerHTML = ` | |
| <span class="toast-icon">${icon}</span> | |
| <span class="toast-message">${message}</span> | |
| `; | |
| container.appendChild(toast); | |
| // Trigger animation | |
| setTimeout(() => { | |
| toast.classList.add('show'); | |
| }, 10); | |
| // Remove toast after duration | |
| setTimeout(() => { | |
| toast.classList.remove('show'); | |
| toast.addEventListener('transitionend', () => { | |
| toast.remove(); | |
| }); | |
| }, 3500); | |
| } | |
| // Initialize grid on load | |
| window.onload = () => { | |
| initGrid(); | |
| window.addEventListener('resize', adjustFlexBasis); | |
| }; | |
| </script> | |
| </body> | |
| </html> |