map-builder / index.html
ale-bena's picture
Update index.html
b9b3738 verified
Raw
History Blame Contribute Delete
65.6 kB
<!DOCTYPE html>
<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 !important;
border: 1px solid var(--border-color);
color: var(--text-main);
box-shadow: none;
}
.btn-outline:hover {
background: rgba(255, 255, 255, 0.04) !important;
border-color: var(--text-muted);
}
.btn-danger {
background: linear-gradient(135deg, var(--accent-red), #b91c1c) !important;
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>