All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m43s
474 lines
18 KiB
HTML
474 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Level Chunk Editor</title>
|
|
<style>
|
|
body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; }
|
|
|
|
/* Sidebar */
|
|
#sidebar {
|
|
width: 300px; background: #2a2a2a; padding: 15px; border-right: 1px solid #444;
|
|
display: flex; flex-direction: column; gap: 12px; overflow-y: auto;
|
|
box-shadow: 2px 0 10px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
/* Canvas Bereich */
|
|
#canvas-wrapper {
|
|
flex: 1; overflow: auto; position: relative; background: #333;
|
|
/* Karomuster */
|
|
background-image: linear-gradient(45deg, #252525 25%, transparent 25%), linear-gradient(-45deg, #252525 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #252525 75%), linear-gradient(-45deg, transparent 75%, #252525 75%);
|
|
background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
|
}
|
|
|
|
canvas {
|
|
background: rgba(0,0,0,0.2); cursor: crosshair; display: block; margin-top: 50px;
|
|
}
|
|
|
|
/* UI Elemente */
|
|
.panel { background: #333; padding: 10px; border: 1px solid #444; border-radius: 4px; }
|
|
|
|
.tools-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
|
|
.tool-btn {
|
|
padding: 8px; background: #444; border: 1px solid #555; color: white;
|
|
cursor: pointer; text-align: left; border-radius: 4px; font-size: 11px;
|
|
display: flex; align-items: center; gap: 8px; overflow: hidden; white-space: nowrap;
|
|
}
|
|
.tool-btn:hover { background: #555; filter: brightness(1.2); }
|
|
.tool-btn.active { background: #ffcc00; color: black; font-weight: bold; border-color: #ffcc00; }
|
|
.tool-thumb { width: 20px; height: 20px; object-fit: contain; background: rgba(0,0,0,0.3); border-radius: 3px; }
|
|
|
|
input, select {
|
|
width: 100%; padding: 5px; background: #1a1a1a; color: white; border: 1px solid #555;
|
|
margin-bottom: 5px; box-sizing: border-box; border-radius: 3px; font-size: 11px;
|
|
}
|
|
input:focus { border-color: #ffcc00; outline: none; }
|
|
|
|
.row { display: flex; gap: 5px; }
|
|
label { font-size: 10px; color: #aaa; display: block; margin-bottom: 1px;}
|
|
h3 { margin: 5px 0 5px 0; color: #ffcc00; border-bottom: 1px solid #555; padding-bottom: 5px; font-size: 13px;}
|
|
|
|
button.action { width:100%; border:none; color:white; cursor:pointer; padding: 10px; margin-top:5px; border-radius:3px; font-weight:bold;}
|
|
.btn-load { background: #2196F3; }
|
|
.btn-save { background: #4caf50; }
|
|
.btn-del { background: #f44336; margin-top: 10px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="sidebar">
|
|
|
|
<h3>📂 LEVEL LADEN</h3>
|
|
<div class="panel">
|
|
<select id="chunk-select"><option value="">Lade Liste...</option></select>
|
|
<div class="row">
|
|
<button class="action btn-load" onclick="loadSelectedChunk()">📂 Laden</button>
|
|
<button class="action" onclick="loadConfig()" style="background:#444; width:40px;">🔄</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>🛠 WERKZEUGE (ASSETS)</h3>
|
|
<div class="tools-grid" id="tools-container">
|
|
<button class="tool-btn active" onclick="setTool('select')">👆 Select</button>
|
|
<button class="tool-btn" onclick="setTool('platform')">🧱 Platform</button>
|
|
</div>
|
|
|
|
<h3>⚙️ EIGENSCHAFTEN</h3>
|
|
<div id="props" class="panel" style="display:none;">
|
|
<div class="row"><div style="flex:2"><label>ID</label><input type="text" id="prop-id" readonly style="color:#777"></div></div>
|
|
|
|
<div class="row">
|
|
<div style="flex:1"><label>X</label><input type="number" id="prop-x" oninput="updateProp()"></div>
|
|
<div style="flex:1"><label>Y</label><input type="number" id="prop-y" oninput="updateProp()"></div>
|
|
</div>
|
|
<div class="row">
|
|
<div style="flex:1"><label>Breite</label><input type="number" id="prop-w" oninput="updateProp()"></div>
|
|
<div style="flex:1"><label>Höhe</label><input type="number" id="prop-h" oninput="updateProp()"></div>
|
|
</div>
|
|
<button class="action btn-del" onclick="deleteSelected()">🗑 Entfernen</button>
|
|
</div>
|
|
|
|
<div style="margin-top:auto;">
|
|
<h3>💾 SPEICHERN</h3>
|
|
<label>Chunk Name (ID)</label>
|
|
<input type="text" id="chunk-name" value="new_chunk">
|
|
|
|
<label>Länge (Pixel)</label>
|
|
<input type="number" id="chunk-width" value="2000">
|
|
|
|
<button class="action btn-save" onclick="saveChunk()">💾 In DB Speichern</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="canvas-wrapper">
|
|
<canvas id="editorCanvas"></canvas>
|
|
<div style="position:absolute; bottom:5px; right:5px; color:white; font-size:10px; opacity:0.5; pointer-events:none;">
|
|
Mausrad: Scrollen | Drag: Bewegen
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// --- STATE ---
|
|
const canvas = document.getElementById('editorCanvas');
|
|
const ctx = canvas.getContext('2d', { alpha: false });
|
|
|
|
let elements = [];
|
|
let selectedElement = null;
|
|
let currentTool = 'select';
|
|
|
|
// Config & Assets
|
|
let loadedConfig = null;
|
|
let TOOL_DEFS = {};
|
|
const imageCache = {};
|
|
let dpr = 1;
|
|
|
|
// Dragging Logic
|
|
let isDragging = false;
|
|
let dragStart = {x:0, y:0};
|
|
|
|
// --- INIT ---
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
loadConfig();
|
|
requestAnimationFrame(renderLoop);
|
|
|
|
function resizeCanvas() {
|
|
const container = canvas.parentElement;
|
|
dpr = window.devicePixelRatio || 1;
|
|
// Interne Größe
|
|
canvas.width = container.clientWidth * dpr;
|
|
canvas.height = container.clientHeight * dpr;
|
|
// CSS Größe
|
|
canvas.style.width = container.clientWidth + 'px';
|
|
canvas.style.height = container.clientHeight + 'px';
|
|
|
|
ctx.scale(dpr, dpr);
|
|
}
|
|
|
|
// --- 1. CONFIG & ASSETS LADEN ---
|
|
async function loadConfig() {
|
|
try {
|
|
const res = await fetch('/api/config');
|
|
const data = await res.json();
|
|
|
|
// Normalize Data
|
|
loadedConfig = {
|
|
obstacles: data.obstacles || data.Obstacles || [],
|
|
chunks: data.chunks || data.Chunks || []
|
|
};
|
|
|
|
buildToolPalette();
|
|
buildChunkList();
|
|
|
|
} catch(e) {
|
|
console.error("Config Fehler:", e);
|
|
alert("Konnte Config nicht laden.");
|
|
}
|
|
}
|
|
|
|
function buildToolPalette() {
|
|
const container = document.getElementById('tools-container');
|
|
// Reset (Select & Platform behalten)
|
|
container.innerHTML = `
|
|
<button class="tool-btn active" onclick="setTool('select')">👆 Select</button>
|
|
<button class="tool-btn" onclick="setTool('platform')">🧱 Platform</button>
|
|
`;
|
|
|
|
// Default Platform Def
|
|
TOOL_DEFS['platform'] = { type: 'platform', id: 'plat', w: 150, h: 20, color: '#8B4513' };
|
|
|
|
// Assets hinzufügen
|
|
loadedConfig.obstacles.forEach(obs => {
|
|
// Speichern in Defs für schnellen Zugriff
|
|
TOOL_DEFS[obs.id] = obs;
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'tool-btn';
|
|
btn.onclick = () => setTool(obs.id);
|
|
|
|
const imgPath = obs.image ? `../assets/${obs.image}` : '';
|
|
btn.innerHTML = `
|
|
<img src="${imgPath}" class="tool-thumb" onerror="this.style.display='none'">
|
|
${obs.id}
|
|
`;
|
|
container.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function buildChunkList() {
|
|
const sel = document.getElementById('chunk-select');
|
|
sel.innerHTML = "<option value=''>Bitte wählen...</option>";
|
|
|
|
loadedConfig.chunks.forEach(c => {
|
|
const opt = document.createElement('option');
|
|
opt.value = c.id;
|
|
opt.innerText = `${c.id} (${c.totalWidth || '?'}px)`;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// --- 2. TOOL LOGIK ---
|
|
function setTool(id) {
|
|
currentTool = id;
|
|
selectedElement = null;
|
|
updateUI();
|
|
|
|
// Buttons highlighten
|
|
document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
|
|
// Einfacher Hack um den Button zu finden (da wir keine IDs auf Buttons haben)
|
|
// In Produktion besser: data-id Attribut nutzen
|
|
const btns = document.querySelectorAll('.tool-btn');
|
|
for(let b of btns) {
|
|
if(b.innerText.includes(id) || (id==='select' && b.innerText.includes('Select'))) {
|
|
b.classList.add('active');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 3. INTERAKTION (Maus) ---
|
|
function getMousePos(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - rect.left), // CSS Pixel (wegen scale(dpr) brauchen wir hier nicht *dpr)
|
|
y: (e.clientY - rect.top)
|
|
};
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', e => {
|
|
const m = getMousePos(e);
|
|
// Scroll Offset des Canvas abziehen?
|
|
// Aktuell scrollt das Canvas nicht (Breite 4000px).
|
|
// Wir nehmen an, das Canvas passt in den Container und hat Overflow:Auto im Wrapper.
|
|
// Der Event Listener ist auf dem Canvas, also gibt clientX relative Koordinaten.
|
|
// Aber Moment: Wenn das Canvas 4000px breit ist, und wir scrollen, ändern sich clientX nicht relativ zum Canvas.
|
|
|
|
// KORREKTUR: Wir müssen scrollLeft addieren wenn das Canvas im Wrapper scrollt?
|
|
// Nein, e.offsetX/Y ist relativ zum Element.
|
|
// Besser:
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left; // Position im Canvas
|
|
const my = e.clientY - rect.top;
|
|
|
|
if (currentTool === 'select') {
|
|
// Selektieren (Rückwärts, oberstes zuerst)
|
|
selectedElement = null;
|
|
for (let i = elements.length - 1; i >= 0; i--) {
|
|
const el = elements[i];
|
|
if (mx >= el.x && mx <= el.x + el.w && my >= el.y && my <= el.y + el.h) {
|
|
selectedElement = el;
|
|
isDragging = true;
|
|
dragStart = { x: mx - el.x, y: my - el.y };
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Neues Objekt platzieren
|
|
const def = TOOL_DEFS[currentTool];
|
|
if (def) {
|
|
const newEl = {
|
|
id: def.id,
|
|
type: def.type,
|
|
// Snap to Grid 10px
|
|
x: Math.floor(mx / 10) * 10,
|
|
y: Math.floor(my / 10) * 10,
|
|
w: def.width || def.w || 30, // Fallback
|
|
h: def.height || def.h || 30,
|
|
// Speichere Referenz auf Def (für Rendering), aber nicht in JSON später
|
|
_def: def
|
|
};
|
|
elements.push(newEl);
|
|
selectedElement = newEl;
|
|
|
|
// Auto-Switch zu Select
|
|
setTool('select');
|
|
}
|
|
}
|
|
updateUI();
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', e => {
|
|
if (isDragging && selectedElement) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
|
|
selectedElement.x = Math.floor((mx - dragStart.x) / 10) * 10;
|
|
selectedElement.y = Math.floor((my - dragStart.y) / 10) * 10;
|
|
updateUI();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => isDragging = false);
|
|
|
|
// --- 4. RENDERING ---
|
|
function getImage(path) {
|
|
if (!path) return null;
|
|
if (imageCache[path]) return imageCache[path];
|
|
const img = new Image();
|
|
img.src = "../assets/" + path;
|
|
imageCache[path] = img;
|
|
return img;
|
|
}
|
|
|
|
function renderLoop() {
|
|
// Logische Größe (CSS Pixel)
|
|
const w = canvas.width / dpr;
|
|
const h = canvas.height / dpr;
|
|
const groundY = 350;
|
|
|
|
ctx.clearRect(0,0,w,h);
|
|
|
|
// Hintergrund
|
|
ctx.fillStyle = "#222"; ctx.fillRect(0, 0, w, h);
|
|
|
|
// Boden
|
|
ctx.fillStyle = "rgba(255,255,255,0.1)"; ctx.fillRect(0, groundY, w, 50);
|
|
ctx.strokeStyle = "#666"; ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(w, groundY); ctx.stroke();
|
|
|
|
// Startbereich Markierung
|
|
ctx.fillStyle = "rgba(0, 255, 0, 0.05)"; ctx.fillRect(0, 0, 800, 400);
|
|
ctx.fillStyle = "#666"; ctx.font="10px monospace"; ctx.fillText("VIEWPORT START (800px)", 10, 20);
|
|
|
|
// Elemente
|
|
elements.forEach(el => {
|
|
// Def laden falls nicht da (passiert beim Laden)
|
|
if (!el._def && TOOL_DEFS[el.id]) el._def = TOOL_DEFS[el.id];
|
|
// Fallback Def
|
|
const def = el._def || { color: '#f0f', imgScale: 1, imgOffsetX:0, imgOffsetY:0 };
|
|
|
|
// 1. HITBOX
|
|
ctx.fillStyle = def.color || '#888';
|
|
|
|
// Wenn Bild da ist, Hitbox transparent
|
|
const imgPath = def.image;
|
|
if (imgPath) ctx.globalAlpha = 0.3;
|
|
|
|
ctx.fillRect(el.x, el.y, el.w, el.h);
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
// Rahmen
|
|
if (el === selectedElement) {
|
|
ctx.strokeStyle = "white"; ctx.lineWidth = 2; ctx.setLineDash([4, 2]);
|
|
} else {
|
|
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 1; ctx.setLineDash([]);
|
|
}
|
|
ctx.strokeRect(el.x, el.y, el.w, el.h);
|
|
|
|
// 2. BILD (Smart Rendering wie im Spiel)
|
|
if (imgPath) {
|
|
const img = getImage(imgPath);
|
|
if (img && img.complete && img.naturalWidth !== 0) {
|
|
const scale = def.imgScale || 1.0;
|
|
const offX = def.imgOffsetX || 0;
|
|
const offY = def.imgOffsetY || 0;
|
|
|
|
const dW = el.w * scale;
|
|
const dH = el.h * scale;
|
|
const bX = el.x + (el.w - dW)/2 + offX;
|
|
const bY = el.y + (el.h - dH) + offY;
|
|
|
|
ctx.drawImage(img, bX, bY, dW, dH);
|
|
}
|
|
}
|
|
|
|
// Label
|
|
ctx.fillStyle = "white"; ctx.font="10px sans-serif";
|
|
ctx.fillText(el.id, el.x, el.y - 4);
|
|
});
|
|
|
|
requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
// --- 5. UI UPDATES ---
|
|
function updateUI() {
|
|
const p = document.getElementById('props');
|
|
if (selectedElement) {
|
|
p.style.display = 'block';
|
|
document.getElementById('prop-id').value = selectedElement.id;
|
|
document.getElementById('prop-x').value = selectedElement.x;
|
|
document.getElementById('prop-y').value = selectedElement.y;
|
|
document.getElementById('prop-w').value = selectedElement.w;
|
|
document.getElementById('prop-h').value = selectedElement.h;
|
|
} else {
|
|
p.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function updateProp() {
|
|
if (!selectedElement) return;
|
|
selectedElement.x = parseFloat(document.getElementById('prop-x').value);
|
|
selectedElement.y = parseFloat(document.getElementById('prop-y').value);
|
|
selectedElement.w = parseFloat(document.getElementById('prop-w').value);
|
|
selectedElement.h = parseFloat(document.getElementById('prop-h').value);
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!selectedElement) return;
|
|
elements = elements.filter(e => e !== selectedElement);
|
|
selectedElement = null;
|
|
updateUI();
|
|
}
|
|
|
|
// --- 6. SAVE / LOAD ---
|
|
function loadSelectedChunk() {
|
|
const id = document.getElementById('chunk-select').value;
|
|
if (!id) return;
|
|
|
|
const chunk = loadedConfig.chunks.find(c => c.id === id);
|
|
if (!chunk) return alert("Chunk nicht gefunden");
|
|
|
|
if (!confirm("Editor leeren und Chunk laden?")) return;
|
|
|
|
document.getElementById('chunk-name').value = chunk.id;
|
|
document.getElementById('chunk-width').value = chunk.totalWidth || 2000;
|
|
|
|
elements = [];
|
|
const adder = (item, type) => {
|
|
elements.push({
|
|
id: item.id || 'plat', // Plattformen haben oft keine ID
|
|
type: type,
|
|
x: item.x, y: item.y, w: item.w, h: item.h,
|
|
_def: TOOL_DEFS[item.id] // Verknüpfung wiederherstellen
|
|
});
|
|
};
|
|
|
|
if (chunk.platforms) chunk.platforms.forEach(p => adder(p, 'platform'));
|
|
if (chunk.obstacles) chunk.obstacles.forEach(o => adder(o, o.type)); // Type aus Objekt oder Fallback
|
|
|
|
selectedElement = null;
|
|
updateUI();
|
|
}
|
|
|
|
async function saveChunk() {
|
|
const name = document.getElementById('chunk-name').value;
|
|
const width = parseInt(document.getElementById('chunk-width').value) || 2000;
|
|
|
|
// Sauberes JSON bauen (ohne _def Referenzen)
|
|
const mapObj = (el) => ({
|
|
id: el.id,
|
|
type: el.type,
|
|
x: el.x, y: el.y, w: el.w, h: el.h
|
|
});
|
|
|
|
const data = {
|
|
id: name,
|
|
totalWidth: width,
|
|
platforms: elements.filter(e => e.type === 'platform').map(mapObj),
|
|
obstacles: elements.filter(e => e.type !== 'platform').map(mapObj)
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/chunks', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (res.ok) alert("✅ Chunk gespeichert!");
|
|
else alert("Fehler beim Speichern");
|
|
} catch(e) { alert("Netzwerkfehler"); }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |