Private
Public Access
1
0

fix
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m43s

This commit is contained in:
Sebastian Unterschütz
2025-12-05 21:56:44 +01:00
parent ae3eb34c0e
commit 141f74c6ad
9 changed files with 944 additions and 391 deletions

View File

@@ -36,7 +36,19 @@ func getEnv(key, fallback string) string {
return fallback return fallback
} }
// config.go
func initGameConfig() { func initGameConfig() {
// 1. Versuche aus Redis zu laden
val, err := rdb.Get(ctx, "config:gamedata").Result()
if err == nil && val != "" {
// Redis hat Daten -> Nutzen!
json.Unmarshal([]byte(val), &defaultConfig)
log.Println("💾 Config aus Redis geladen.")
} else {
// Redis ist leer -> Hardcoded Defaults laden (Bootstrap)
log.Println("⚠️ Keine Config in Redis -> Nutze Hardcoded Defaults.")
defaultConfig = GameConfig{ defaultConfig = GameConfig{
Obstacles: []ObstacleDef{ Obstacles: []ObstacleDef{
// --- HINDERNISSE --- // --- HINDERNISSE ---
@@ -60,8 +72,9 @@ func initGameConfig() {
Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"}, Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"},
} }
log.Println("✅ Config mit Powerups geladen") }
// Chunks laden (bleibt wie vorher)
defaultConfig.Chunks = loadChunksFromRedis() defaultConfig.Chunks = loadChunksFromRedis()
} }

View File

@@ -3,9 +3,12 @@ package main
import ( import (
"encoding/json" "encoding/json"
"html" "html"
"io"
"log" "log"
"math/rand" "math/rand"
"net/http" "net/http"
"os"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -344,3 +347,67 @@ func handleAdminBadwords(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
} }
func handleAdminUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", 405)
return
}
r.ParseMultipartForm(10 << 20)
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Keine Datei gefunden", 400)
return
}
defer file.Close()
filename := filepath.Base(header.Filename)
targetPath := filepath.Join("./static/assets", filename)
// Datei erstellen
dst, err := os.Create(targetPath)
if err != nil {
http.Error(w, "Konnte Datei nicht speichern (Rechte?)", 500)
return
}
defer dst.Close()
// Inhalt kopieren
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "Fehler beim Schreiben", 500)
return
}
log.Printf("📂 UPLOAD: %s erfolgreich gespeichert.", filename)
// JSON Antwort mit dem Pfad zurückgeben
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"filename": filename,
})
}
// 2. CONFIG SPEICHERN (Redis)
func handleAdminSaveConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST", 405)
return
}
var newConfig GameConfig
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, "Bad JSON", 400)
return
}
data, _ := json.Marshal(newConfig)
rdb.Set(ctx, "config:gamedata", data, 0)
defaultConfig.Obstacles = newConfig.Obstacles
defaultConfig.Backgrounds = newConfig.Backgrounds
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,14 +1,3 @@
apiVersion: v1
kind: Service
metadata:
name: escape-game
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: escape-game
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -16,7 +5,7 @@ metadata:
labels: labels:
app: escape-game app: escape-game
spec: spec:
replicas: 1 replicas: 1 # Kannst du bei RWX auch hochskalieren!
selector: selector:
matchLabels: matchLabels:
app: escape-game app: escape-game
@@ -25,9 +14,30 @@ spec:
labels: labels:
app: escape-game app: escape-game
spec: spec:
initContainers:
- name: init-assets
image: ${IMAGE_NAME}
command: ["/bin/sh", "-c"]
args:
- |
echo "Prüfe Assets..."
# Wenn das Volume leer ist (nur lost+found), kopiere Originale
if [ -z "$(ls -A /mnt/assets)" ]; then
echo "Volume leer. Kopiere Basis-Assets..."
cp -r /app/static/assets/* /mnt/assets/
else
echo "Assets existieren bereits. Überspringe Copy."
# Optional: 'cp -n' nutzen um neue Default-Assets zu ergänzen ohne Uploads zu überschreiben
cp -rn /app/static/assets/* /mnt/assets/ || true
fi
volumeMounts:
- name: assets-vol
mountPath: /mnt/assets
# --- MAIN CONTAINER ---
containers: containers:
- name: game - name: game
image: ${IMAGE_NAME} # Wird von CI ersetzt image: ${IMAGE_NAME}
ports: ports:
- containerPort: 8080 - containerPort: 8080
env: env:
@@ -37,24 +47,34 @@ spec:
value: "${ADMIN_USER}" value: "${ADMIN_USER}"
- name: ADMIN_PASS - name: ADMIN_PASS
value: "${ADMIN_PASS}" value: "${ADMIN_PASS}"
# HIER DAS VOLUME EINHÄNGEN
volumeMounts:
- name: assets-vol
mountPath: /app/static/assets
resources: resources:
requests: requests:
memory: "64Mi" memory: "128Mi"
cpu: "50m" cpu: "100m"
limits: limits:
memory: "256Mi" memory: "512Mi"
cpu: "500m" cpu: "1000m"
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /
port: 8080 port: 8080
initialDelaySeconds: 10 initialDelaySeconds: 15
periodSeconds: 15 periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /
port: 8080 port: 8080
initialDelaySeconds: 5 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
volumes:
- name: assets-vol
persistentVolumeClaim:
claimName: game-assets-pvc

12
k8s/pvc.yaml Normal file
View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: game-assets-pvc
namespace: ${TARGET_NS}
spec:
accessModes:
- ReadWriteMany
storageClassName: longhorn
resources:
requests:
storage: 2Gi

View File

@@ -48,6 +48,11 @@ func main() {
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList))) http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))
http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction))) http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction)))
http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks))) http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks)))
http.HandleFunc("/api/admin/upload", Logger(BasicAuth(handleAdminUpload)))
http.HandleFunc("/api/admin/save-config", Logger(BasicAuth(handleAdminSaveConfig)))
http.HandleFunc("/admin/assets", Logger(BasicAuth(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./secure/assets.html")
})))
http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage))) http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage)))
http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage))) http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage)))

449
secure/assets.html Normal file
View File

@@ -0,0 +1,449 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Asset Manager</title>
<style>
body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; }
/* Sidebar: Liste */
#list-panel { width: 260px; background: #222; border-right: 1px solid #444; overflow-y: auto; display: flex; flex-direction: column; }
.list-item { padding: 10px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: background 0.2s; }
.list-item:hover { background: #333; }
.list-item.active { background: #2196F3; color: white; border-left: 4px solid #fff; }
.list-thumb { width: 32px; height: 32px; object-fit: contain; background: rgba(0,0,0,0.3); border-radius: 4px; }
/* Main: Editor */
#editor-panel { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 15px; }
/* Preview Canvas Box */
.preview-box {
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-color: #333;
border: 2px solid #555; height: 300px; position: relative;
border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
#preview-canvas { width: 100%; height: 100%; display: block; }
/* Formular Gruppen */
.group { background: #2a2a2a; padding: 12px; border: 1px solid #444; border-radius: 6px; }
.group-title { font-size: 11px; color: #2196F3; font-weight: bold; margin-bottom: 8px; text-transform: uppercase; border-bottom: 1px solid #444; padding-bottom: 4px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
label { font-size: 10px; color: #aaa; text-transform: uppercase; display: block; margin-bottom: 3px; }
input, select {
width: 100%; background: #151515; border: 1px solid #555; color: white;
padding: 8px; box-sizing: border-box; border-radius: 4px; font-family: monospace;
}
input:focus { border-color: #ffcc00; outline: none; }
/* Checkbox Style */
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; background: #111; padding: 8px; border-radius: 4px; border: 1px solid #444; }
.checkbox-label input { width: auto; margin: 0; }
.checkbox-label:hover { background: #333; }
button { padding: 10px; cursor: pointer; border: none; font-weight: bold; border-radius: 4px; transition: all 0.2s; }
button:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-save { background: #4caf50; color: white; width: 100%; margin-top: 10px; font-size: 14px; padding: 12px; }
.btn-upload { background: #FF9800; color: white; white-space: nowrap;}
.btn-add { background: #2196F3; color: white; width: 100%; margin-top: auto; border-radius: 0; padding: 15px; }
.btn-delete { background: #f44336; color: white; margin-top: 10px; width: 100%; }
</style>
</head>
<body>
<div id="list-panel">
<h3 style="padding:15px; margin:0; background:#2a2a2a; border-bottom:1px solid #444; color:#ffcc00;">📦 ASSET MANAGER</h3>
<div id="asset-list">Lade Daten...</div>
<button class="btn-add" onclick="createNew()">+ NEUES OBJEKT</button>
</div>
<div id="editor-panel" style="display:none;">
<div class="preview-box">
<canvas id="preview-canvas"></canvas>
<div style="position:absolute; bottom:5px; right:5px; font-size:10px; color:white; opacity:0.5; pointer-events:none;">
Grün: Spieler | Rot: Hitbox | Blau: Textur
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex:1; display:flex; flex-direction:column; gap:15px;">
<div class="group">
<div class="group-title">Basis</div>
<div class="form-grid">
<div>
<label>ID (Einzigartig)</label>
<input type="text" id="inp-id" oninput="saveLocalState()">
</div>
<div>
<label>Typ</label>
<select id="inp-type" oninput="saveLocalState()">
<option value="obstacle">Hindernis</option>
<option value="teacher">Lehrer (Tödlich)</option>
<option value="coin">Coin</option>
<option value="powerup">PowerUp</option>
</select>
</div>
</div>
<div style="margin-top:10px;">
<label>Sprechblasen (Komma getrennt)</label>
<input type="text" id="inp-speech" placeholder="Halt!, Hier geblieben!" oninput="saveLocalState()">
</div>
<div style="margin-top:10px;">
<label class="checkbox-label">
<input type="checkbox" id="inp-norandom" onchange="saveLocalState()">
<span style="color:#ff8a80;">⛔ Nicht Random spawnen</span>
</label>
<small style="color:#666; margin-left:5px;">Nur manuell im Editor platzierbar</small>
</div>
</div>
<div class="group">
<div class="group-title">Textur & Upload</div>
<label>Dateiname (/static/assets/)</label>
<div style="display:flex; gap:5px;">
<input type="text" id="inp-image" placeholder="bild.png" oninput="updateImagePreview()">
<input type="file" id="file-upload" style="display:none" onchange="uploadFile()">
<button class="btn-upload" onclick="document.getElementById('file-upload').click()">📂 Upload</button>
</div>
<div class="form-grid" style="margin-top:10px;">
<div><label>Fallback Farbe</label><input type="color" id="inp-color" oninput="draw()" style="height:35px; padding:2px;"></div>
</div>
</div>
</div>
<div style="flex:1; display:flex; flex-direction:column; gap:15px;">
<div class="group" style="border-color:#ff4444;">
<div class="group-title" style="color:#ff4444;">🔴 Hitbox (Physik)</div>
<div class="form-grid">
<div><label>Breite</label><input type="number" id="inp-w" oninput="draw()"></div>
<div><label>Höhe</label><input type="number" id="inp-h" oninput="draw()"></div>
</div>
<div style="margin-top:10px;">
<label>Y-Offset (Schweben)</label>
<input type="number" id="inp-yoff" oninput="draw()">
<small style="color:#666">Positiv = Höher</small>
</div>
</div>
<div class="group" style="border-color:#2196F3;">
<div class="group-title" style="color:#2196F3;">🖼️ Optik (Textur)</div>
<div class="form-grid">
<div><label>Scale</label><input type="number" step="0.1" id="inp-scale" value="1.0" oninput="draw()"></div>
<div></div>
<div><label>Img Offset X</label><input type="number" id="inp-offx" value="0" oninput="draw()"></div>
<div><label>Img Offset Y</label><input type="number" id="inp-offy" value="0" oninput="draw()"></div>
</div>
</div>
</div>
</div>
<div style="margin-top:auto; padding-top:20px; border-top:1px solid #444;">
<button class="btn-save" onclick="saveAll()">💾 CONFIG SPEICHERN & SERVER NEUSTARTEN</button>
<button class="btn-delete" onclick="deleteCurrent()">🗑 Dieses Asset löschen</button>
</div>
</div>
<script>
// Globale Variablen
let config = { obstacles: [], backgrounds: [] };
let currentIdx = -1;
// Canvas Context
const canvas = document.getElementById('preview-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let imgCache = new Image();
let dpr = 1;
// --- INIT ---
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
loadData();
function resizeCanvas() {
const container = canvas.parentElement;
const displayWidth = container.clientWidth;
const displayHeight = container.clientHeight;
dpr = window.devicePixelRatio || 1;
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
ctx.scale(dpr, dpr);
draw();
}
// 1. DATEN LADEN
async function loadData() {
try {
const res = await fetch('/api/config');
if(!res.ok) throw new Error("Server antwortet nicht");
const data = await res.json();
// Safety Check & Lowercase Enforcing
config = {
obstacles: data.obstacles || data.Obstacles || [],
backgrounds: data.backgrounds || data.Backgrounds || []
};
renderList();
} catch (e) {
console.error(e);
document.getElementById('asset-list').innerHTML = "<div style='padding:10px; color:red'>Fehler beim Laden!</div>";
}
}
// 2. LISTE RENDERN
function renderList() {
const list = document.getElementById('asset-list');
list.innerHTML = "";
if (config.obstacles.length === 0) {
list.innerHTML = "<div style='padding:15px; color:#666; text-align:center;'>Keine Assets.<br>Erstelle eins!</div>";
return;
}
config.obstacles.forEach((obs, idx) => {
const div = document.createElement('div');
div.className = `list-item ${idx === currentIdx ? 'active' : ''}`;
const imgPath = obs.image ? `../assets/${obs.image}` : '';
div.innerHTML = `
<img src="${imgPath}" class="list-thumb" onerror="this.style.opacity=0">
<div>
<b style="font-size:12px;">${obs.id || 'Ohne ID'}</b><br>
<small style="color:#aaa;">${obs.type}</small>
</div>
`;
div.onclick = () => selectItem(idx);
list.appendChild(div);
});
}
// 3. ITEM AUSWÄHLEN
function selectItem(idx) {
currentIdx = idx;
const obs = config.obstacles[idx];
if (!obs) return;
document.getElementById('editor-panel').style.display = 'flex';
const set = (id, val) => document.getElementById(id).value = val;
set('inp-id', obs.id || '');
set('inp-type', obs.type || 'obstacle');
set('inp-image', obs.image || '');
set('inp-w', obs.width || 30);
set('inp-h', obs.height || 30);
set('inp-yoff', obs.yOffset || 0);
set('inp-scale', obs.imgScale || 1.0);
set('inp-offx', obs.imgOffsetX || 0);
set('inp-offy', obs.imgOffsetY || 0);
set('inp-color', obs.color || '#ff0000');
const speech = obs.speechLines ? obs.speechLines.join(', ') : '';
set('inp-speech', speech);
// Checkbox: NoRandomSpawn
document.getElementById('inp-norandom').checked = obs.noRandomSpawn === true;
updateImagePreview();
renderList();
}
// 4. NEUES ITEM
function createNew() {
const newObj = {
id: 'neu_' + Math.floor(Math.random()*1000),
type: 'obstacle',
width: 30, height: 30,
color: '#00ff00',
imgScale: 1.0, imgOffsetX: 0, imgOffsetY: 0,
noRandomSpawn: false
};
config.obstacles.push(newObj);
selectItem(config.obstacles.length - 1);
const list = document.getElementById('list-panel');
setTimeout(() => list.scrollTop = list.scrollHeight, 50);
}
// 5. LÖSCHEN
function deleteCurrent() {
if (currentIdx === -1) return;
if (!confirm("Dieses Asset wirklich löschen?")) return;
config.obstacles.splice(currentIdx, 1);
document.getElementById('editor-panel').style.display = 'none';
currentIdx = -1;
renderList();
}
// 6. UPLOAD
async function uploadFile() {
const input = document.getElementById('file-upload');
if (input.files.length === 0) return;
const formData = new FormData();
formData.append("file", input.files[0]);
try {
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error("Upload Error " + res.status);
const data = await res.json();
document.getElementById('inp-image').value = data.filename;
saveLocalState();
updateImagePreview();
} catch(e) {
alert("Upload fehlgeschlagen: " + e.message);
}
}
// 7. SPEICHERN
async function saveAll() {
saveLocalState();
try {
const res = await fetch('/api/admin/save-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
if (res.ok) {
alert("✅ Gespeichert! Server geupdatet.");
renderList();
} else {
alert("❌ Fehler beim Speichern");
}
} catch(e) {
alert("Netzwerkfehler: " + e);
}
}
// --- HELPER ---
function saveLocalState() {
if (currentIdx === -1) return;
const obs = config.obstacles[currentIdx];
const get = (id) => document.getElementById(id).value;
const getNum = (id) => parseFloat(document.getElementById(id).value) || 0;
obs.id = get('inp-id');
obs.type = get('inp-type');
obs.image = get('inp-image');
obs.width = getNum('inp-w');
obs.height = getNum('inp-h');
obs.yOffset = getNum('inp-yoff');
obs.imgScale = getNum('inp-scale');
obs.imgOffsetX = getNum('inp-offx');
obs.imgOffsetY = getNum('inp-offy');
obs.color = get('inp-color');
// Random Flag
obs.noRandomSpawn = document.getElementById('inp-norandom').checked;
const s = get('inp-speech');
obs.speechLines = s ? s.split(',').map(x => x.trim()).filter(x => x) : [];
obs.canTalk = obs.speechLines.length > 0;
draw();
}
function updateImagePreview() {
const path = document.getElementById('inp-image').value;
if (path) {
imgCache.src = "../assets/" + path;
} else {
imgCache.src = "";
}
imgCache.onload = () => { draw(); renderList(); };
imgCache.onerror = draw;
}
// --- DRAWING ---
function draw() {
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const groundY = h * 0.8;
const centerX = w / 2;
ctx.clearRect(0,0,w,h);
// Boden
ctx.fillStyle = "#222"; ctx.fillRect(0, groundY, w, 2);
ctx.fillStyle = "#444"; ctx.font="10px monospace"; ctx.fillText(`GROUND Y=${Math.floor(groundY)}`, 5, groundY+12);
// Player Dummy
const pX = centerX - 100;
ctx.fillStyle = "#33cc33"; ctx.fillRect(pX, groundY-50, 30, 50);
ctx.fillStyle = "white"; ctx.fillText("Player", pX, groundY-55);
if (currentIdx === -1) return;
// Werte lesen
const width = parseFloat(document.getElementById('inp-w').value) || 30;
const height = parseFloat(document.getElementById('inp-h').value) || 30;
const yOff = parseFloat(document.getElementById('inp-yoff').value) || 0;
const scale = parseFloat(document.getElementById('inp-scale').value) || 1.0;
const offX = parseFloat(document.getElementById('inp-offx').value) || 0;
const offY = parseFloat(document.getElementById('inp-offy').value) || 0;
const color = document.getElementById('inp-color').value;
// Objekt Position
const oX = centerX + 50 - (width / 2);
const oY = groundY - height - yOff;
// 1. HITBOX
ctx.fillStyle = color;
ctx.globalAlpha = 0.3;
ctx.fillRect(oX, oY, width, height);
ctx.globalAlpha = 1.0;
ctx.strokeStyle = "red"; ctx.lineWidth = 2;
ctx.strokeRect(oX, oY, width, height);
ctx.fillStyle = "red";
ctx.fillText(`Hitbox: ${width}x${height}`, oX, oY-5);
// 2. TEXTUR
if (imgCache.complete && imgCache.naturalHeight !== 0) {
const dW = width * scale;
const dH = height * scale;
// Zentriert & Unten Bündig + Offset
const bX = oX + (width - dW)/2 + offX;
const bY = oY + (height - dH) + offY;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(imgCache, bX, bY, dW, dH);
ctx.strokeStyle = "cyan"; ctx.lineWidth = 1;
ctx.strokeRect(bX, bY, dW, dH);
}
// Mitte-Linie
ctx.strokeStyle = "#444"; ctx.lineWidth = 1; ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(centerX, 0); ctx.lineTo(centerX, h); ctx.stroke(); ctx.setLineDash([]);
}
</script>
</body>
</html>

View File

@@ -2,315 +2,257 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Ultimate Chunk Editor (+Templates)</title> <title>Level Chunk Editor</title>
<style> <style>
body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; } body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; }
/* Sidebar */
#sidebar { #sidebar {
width: 340px; background: #2a2a2a; padding: 15px; border-right: 2px solid #444; width: 300px; background: #2a2a2a; padding: 15px; border-right: 1px solid #444;
display: flex; flex-direction: column; gap: 10px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.5); box-shadow: 2px 0 10px rgba(0,0,0,0.5);
} }
/* Canvas Bereich */
#canvas-wrapper { #canvas-wrapper {
flex: 1; overflow: auto; position: relative; background: #333; 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-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; 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; } canvas {
background: rgba(0,0,0,0.2); cursor: crosshair; display: block; margin-top: 50px;
}
.tool-btn { padding: 8px; background: #444; border: 1px solid #555; color: white; cursor: pointer; text-align: left; border-radius: 4px; font-size: 12px; } /* 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-btn.active { background: #ffcc00; color: black; font-weight: bold; border-color: #ffcc00; }
.tool-btn:hover:not(.active) { background: #555; } .tool-thumb { width: 20px; height: 20px; object-fit: contain; background: rgba(0,0,0,0.3); border-radius: 3px; }
.panel { background: #333; padding: 10px; border: 1px solid #444; border-radius: 4px; margin-bottom: 5px; } input, select {
.group-title { font-size: 10px; color: #2196F3; font-weight: bold; margin-bottom: 5px; text-transform: uppercase; border-bottom: 1px solid #444; padding-bottom: 2px; } 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, select { width: 100%; padding: 4px; 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; } input:focus { border-color: #ffcc00; outline: none; }
.row { display: flex; gap: 5px; } .row { display: flex; gap: 5px; }
label { font-size: 10px; color: #aaa; display: block; margin-bottom: 1px;} label { font-size: 10px; color: #aaa; display: block; margin-bottom: 1px;}
h3 { margin: 10px 0 5px 0; color: #ffcc00; border-bottom: 1px solid #555; padding-bottom: 5px; font-size: 14px;} 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: 8px; margin-top:5px; border-radius:3px; font-weight:bold;} 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; }
/* Template Liste Style */ .btn-save { background: #4caf50; }
.template-item { display: flex; justify-content: space-between; align-items: center; background: #222; padding: 5px; margin-bottom: 2px; font-size: 11px; border: 1px solid #444; cursor: pointer; } .btn-del { background: #f44336; margin-top: 10px;}
.template-item:hover { background: #444; }
.template-del { color: #f44336; font-weight: bold; padding: 0 5px; cursor: pointer; }
</style> </style>
</head> </head>
<body> <body>
<div id="sidebar"> <div id="sidebar">
<h3>🎨 VORLAGEN</h3> <h3>📂 LEVEL LADEN</h3>
<div class="panel"> <div class="panel">
<div style="display:flex; gap:5px;"> <select id="chunk-select"><option value="">Lade Liste...</option></select>
<input type="text" id="tpl-name" placeholder="Name (z.B. Teacher Hut)" style="margin:0;"> <div class="row">
<button onclick="saveTemplate()" style="background:#4caf50; border:none; color:white; cursor:pointer; width:40px;">💾</button> <button class="action btn-load" onclick="loadSelectedChunk()">📂 Laden</button>
<button class="action" onclick="loadConfig()" style="background:#444; width:40px;">🔄</button>
</div> </div>
<div id="template-list" style="max-height: 100px; overflow-y: auto; margin-top: 5px; border: 1px solid #444;">
</div>
<button class="action" onclick="applyTemplate()" style="background:#2196F3; font-size: 11px;">✨ Auf Selektion anwenden</button>
</div> </div>
<h3>🛠 TOOLS</h3> <h3>🛠 WERKZEUGE (ASSETS)</h3>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:5px;"> <div class="tools-grid" id="tools-container">
<button class="tool-btn active" onclick="setTool('select')">👆 Select</button> <button class="tool-btn active" onclick="setTool('select')">👆 Select</button>
<button class="tool-btn" onclick="setTool('platform')">🧱 Platform</button> <button class="tool-btn" onclick="setTool('platform')">🧱 Platform</button>
<button class="tool-btn" onclick="setTool('teacher')">👨‍🏫 Teacher</button>
<button class="tool-btn" onclick="setTool('principal')">👿 Boss</button>
<button class="tool-btn" onclick="setTool('coin')">🪙 Coin</button>
<button class="tool-btn" onclick="setTool('powerup')">⚡ PowerUp</button>
</div> </div>
<h3>⚙️ EIGENSCHAFTEN</h3> <h3>⚙️ EIGENSCHAFTEN</h3>
<div id="props" class="panel" style="display:none;"> <div id="props" class="panel" style="display:none;">
<div class="group-title">Basis & Physik</div> <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:2"><label>ID</label><input type="text" id="prop-id" oninput="updateProp()"></div>
<div style="flex:1"><label>Type</label><input type="text" id="prop-type" readonly style="color:#666;"></div>
</div>
<div class="row"> <div class="row">
<div style="flex:1"><label>X</label><input type="number" id="prop-x" oninput="updateProp()"></div> <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 style="flex:1"><label>Y</label><input type="number" id="prop-y" oninput="updateProp()"></div>
</div> </div>
<div class="row"> <div class="row">
<div style="flex:1"><label>Width</label><input type="number" id="prop-w" oninput="updateProp()"></div> <div style="flex:1"><label>Breite</label><input type="number" id="prop-w" oninput="updateProp()"></div>
<div style="flex:1"><label>Height</label><input type="number" id="prop-h" oninput="updateProp()"></div> <div style="flex:1"><label>Höhe</label><input type="number" id="prop-h" oninput="updateProp()"></div>
</div> </div>
<button class="action btn-del" onclick="deleteSelected()">🗑 Entfernen</button>
<div class="group-title" style="margin-top:10px;">🖼️ Optik (Textur)</div>
<label>Bild Datei (../../assets/)</label>
<input type="text" id="prop-image" oninput="updateProp()">
<div class="row">
<div style="flex:1"><label>Scale</label><input type="number" step="0.1" id="prop-scale" oninput="updateProp()"></div>
<div style="flex:1"><label>Farbe</label><input type="color" id="prop-color" oninput="updateProp()" style="height:25px; padding:0;"></div>
</div>
<div class="row">
<div style="flex:1"><label>Off X</label><input type="number" id="prop-imgx" oninput="updateProp()"></div>
<div style="flex:1"><label>Off Y</label><input type="number" id="prop-imgy" oninput="updateProp()"></div>
</div>
<button class="action" onclick="deleteSelected()" style="background:#f44336; margin-top:10px;">🗑 Löschen</button>
</div> </div>
<div style="margin-top:auto;"> <div style="margin-top:auto;">
<h3>💾 LEVEL DATEI</h3> <h3>💾 SPEICHERN</h3>
<div class="panel"> <label>Chunk Name (ID)</label>
<select id="chunk-select"><option value="">Lade Liste...</option></select> <input type="text" id="chunk-name" value="new_chunk">
<div class="row">
<button class="action" onclick="loadSelectedChunk()" style="background:#2196F3; margin-top:2px;">📂 Laden</button>
<button class="action" onclick="refreshList()" style="background:#444; margin-top:2px;">🔄</button>
</div>
<label style="margin-top:5px">Chunk Name</label> <label>Länge (Pixel)</label>
<input type="text" id="chunk-name" value="level_01">
<label>Länge</label>
<input type="number" id="chunk-width" value="2000"> <input type="number" id="chunk-width" value="2000">
<button class="action" onclick="saveChunk()" style="background:#4caf50;">💾 Speichern (DB)</button> <button class="action btn-save" onclick="saveChunk()">💾 In DB Speichern</button>
<button class="action" onclick="exportJSON()" style="background:#FF9800;">📋 JSON Copy</button>
</div>
</div> </div>
</div> </div>
<div id="canvas-wrapper"> <div id="canvas-wrapper">
<canvas id="editorCanvas" width="4000" height="500"></canvas> <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> </div>
<script> <script>
// --- STATE ---
const canvas = document.getElementById('editorCanvas'); const canvas = document.getElementById('editorCanvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: false });
// State
let elements = []; let elements = [];
let selectedElement = null; let selectedElement = null;
let currentTool = 'select'; let currentTool = 'select';
// Config & Assets
let loadedConfig = null;
let TOOL_DEFS = {};
const imageCache = {};
let dpr = 1;
// Dragging Logic
let isDragging = false; let isDragging = false;
let dragStart = {x:0, y:0}; let dragStart = {x:0, y:0};
const imageCache = {};
let loadedConfig = null;
// --- TEMPLATE SYSTEM (NEU) ---
let myTemplates = JSON.parse(localStorage.getItem('editor_templates') || '{}');
let selectedTemplateKey = null;
function renderTemplateList() {
const list = document.getElementById('template-list');
list.innerHTML = "";
if (Object.keys(myTemplates).length === 0) {
list.innerHTML = "<div style='color:#666; padding:5px; font-style:italic;'>Keine Vorlagen</div>";
return;
}
for (let key in myTemplates) {
const div = document.createElement('div');
div.className = 'template-item';
if (selectedTemplateKey === key) div.style.background = "#2196F3";
div.innerHTML = `<span>${key}</span>`;
// Delete Btn
const del = document.createElement('span');
del.className = 'template-del';
del.innerText = '×';
del.onclick = (e) => { e.stopPropagation(); deleteTemplate(key); };
div.appendChild(del);
div.onclick = () => { selectedTemplateKey = key; renderTemplateList(); };
list.appendChild(div);
}
}
function saveTemplate() {
if (!selectedElement) { alert("Bitte erst ein Objekt auswählen, um dessen Werte zu speichern."); return; }
const name = document.getElementById('tpl-name').value;
if (!name) { alert("Bitte Namen eingeben"); return; }
// Wir speichern nur die visuellen/hitbox Eigenschaften, NICHT die Position!
const tpl = {
w: selectedElement.w,
h: selectedElement.h,
color: selectedElement.color,
image: selectedElement.image,
imgScale: selectedElement.imgScale,
imgOffsetX: selectedElement.imgOffsetX,
imgOffsetY: selectedElement.imgOffsetY,
type: selectedElement.type,
id: selectedElement.id // ID auch übernehmen (z.B. "teacher_hard")
};
myTemplates[name] = tpl;
localStorage.setItem('editor_templates', JSON.stringify(myTemplates));
document.getElementById('tpl-name').value = "";
renderTemplateList();
}
function applyTemplate() {
if (!selectedElement) { alert("Bitte ein Objekt auswählen."); return; }
if (!selectedTemplateKey || !myTemplates[selectedTemplateKey]) { alert("Bitte eine Vorlage auswählen."); return; }
const tpl = myTemplates[selectedTemplateKey];
// Werte übertragen
selectedElement.w = tpl.w;
selectedElement.h = tpl.h;
selectedElement.color = tpl.color;
selectedElement.image = tpl.image;
selectedElement.imgScale = tpl.imgScale;
selectedElement.imgOffsetX = tpl.imgOffsetX;
selectedElement.imgOffsetY = tpl.imgOffsetY;
// ID und Type nur ändern wenn gewünscht, wir lassen Type meist gleich
// selectedElement.id = tpl.id;
updateUI();
draw();
}
function deleteTemplate(key) {
if(!confirm(`Vorlage '${key}' löschen?`)) return;
delete myTemplates[key];
localStorage.setItem('editor_templates', JSON.stringify(myTemplates));
if(selectedTemplateKey === key) selectedTemplateKey = null;
renderTemplateList();
}
// --- DEFAULTS ---
const DEFAULTS = {
platform: { w: 150, h: 20, color: '#8B4513', type: 'platform', id: 'plat', image: '' },
teacher: { w: 30, h: 60, color: '#000080', type: 'teacher', id: 'teacher', image: 'teacher1.png' },
principal: { w: 40, h: 70, color: '#000000', type: 'teacher', id: 'principal', image: 'principal1.png' },
coin: { w: 20, h: 20, color: '#FFD700', type: 'coin', id: 'coin0', image: 'coin1.png' },
powerup: { w: 30, h: 30, color: '#00FF00', type: 'powerup', id: 'p_boot', image: 'powerup_boot1.png' }
};
// --- INIT --- // --- INIT ---
renderTemplateList(); // Templates laden resizeCanvas();
refreshList(); window.addEventListener('resize', resizeCanvas);
loadConfig();
requestAnimationFrame(renderLoop); requestAnimationFrame(renderLoop);
// --- HELPER --- function resizeCanvas() {
function getImage(path) { const container = canvas.parentElement;
if (!path) return null; dpr = window.devicePixelRatio || 1;
if (imageCache[path]) return imageCache[path]; // Interne Größe
const img = new Image(); canvas.width = container.clientWidth * dpr;
img.src = "../../assets/" + path; canvas.height = container.clientHeight * dpr;
img.onerror = () => { img.src = "../../assets/" + path; }; // CSS Größe
imageCache[path] = img; canvas.style.width = container.clientWidth + 'px';
return img; canvas.style.height = container.clientHeight + 'px';
ctx.scale(dpr, dpr);
} }
function renderLoop() { draw(); requestAnimationFrame(renderLoop); } // --- 1. CONFIG & ASSETS LADEN ---
async function loadConfig() {
try {
const res = await fetch('/api/config');
const data = await res.json();
function draw() { // Normalize Data
ctx.clearRect(0,0,canvas.width, canvas.height); loadedConfig = {
obstacles: data.obstacles || data.Obstacles || [],
chunks: data.chunks || data.Chunks || []
};
// Grid buildToolPalette();
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0, 350, canvas.width, 50); buildChunkList();
ctx.strokeStyle = "#aaa"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 350); ctx.lineTo(canvas.width, 350); ctx.stroke();
// Start Zone } catch(e) {
ctx.fillStyle = "rgba(0,255,0,0.05)"; ctx.fillRect(0, 0, 800, 350); console.error("Config Fehler:", e);
ctx.fillStyle = "#aaa"; ctx.font = "12px Arial"; ctx.fillText("Viewport Start (800px)", 10, 20); alert("Konnte Config nicht laden.");
elements.forEach(el => {
const w = el.w; const h = el.h; const x = el.x; const y = el.y;
const scale = el.imgScale || 1.0;
const offX = el.imgOffsetX || 0;
const offY = el.imgOffsetY || 0;
const imgPath = el.image;
// Hitbox
ctx.fillStyle = el.color || '#888';
if (imgPath) ctx.fillStyle = "rgba(255, 0, 0, 0.2)";
ctx.fillRect(x, y, w, h);
if(el === selectedElement) {
ctx.strokeStyle = "white"; ctx.lineWidth = 2; ctx.setLineDash([5, 3]);
} else {
ctx.strokeStyle = "rgba(0,0,0,0.3)"; ctx.lineWidth = 1; ctx.setLineDash([]);
}
ctx.strokeRect(x, y, w, h);
// Bild
const img = getImage(imgPath);
if (img && img.complete && img.naturalHeight !== 0) {
const drawW = w * scale;
const drawH = h * scale;
const baseX = x + (w - drawW) / 2;
const baseY = y + (h - drawH);
const finalX = baseX + offX;
const finalY = baseY + offY;
ctx.drawImage(img, finalX, finalY, drawW, drawH);
if(el === selectedElement) {
ctx.strokeStyle = "#2196F3"; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.strokeRect(finalX, finalY, drawW, drawH);
} }
} }
// Label function buildToolPalette() {
ctx.fillStyle = "white"; ctx.font = "bold 10px sans-serif"; const container = document.getElementById('tools-container');
ctx.shadowColor="black"; ctx.shadowBlur=3; // Reset (Select & Platform behalten)
ctx.fillText(el.id, x, y - 4); container.innerHTML = `
ctx.shadowBlur=0; <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);
}); });
} }
// --- INTERACTION --- function buildChunkList() {
canvas.addEventListener('mousedown', e => { 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(); const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left; 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; const my = e.clientY - rect.top;
if (currentTool === 'select') { if (currentTool === 'select') {
// Selektieren (Rückwärts, oberstes zuerst)
selectedElement = null; selectedElement = null;
for (let i = elements.length - 1; i >= 0; i--) { for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i]; const el = elements[i];
@@ -322,24 +264,25 @@
} }
} }
} else { } else {
const def = DEFAULTS[currentTool]; // Neues Objekt platzieren
const def = TOOL_DEFS[currentTool];
if (def) { if (def) {
const newEl = { const newEl = {
type: def.type, id: def.id, id: def.id,
x: Math.floor(mx/10)*10, y: Math.floor(my/10)*10, type: def.type,
w: def.w, h: def.h, color: def.color, // Snap to Grid 10px
image: def.image, x: Math.floor(mx / 10) * 10,
imgScale: 1.0, imgOffsetX: 0, imgOffsetY: 0 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
}; };
// --- AUTO APPLY TEMPLATE? ---
// Optional: Wenn man ein Template ausgewählt hat, könnte man es direkt anwenden.
// Das machen wir aber lieber manuell per "Apply".
elements.push(newEl); elements.push(newEl);
selectedElement = newEl; selectedElement = newEl;
currentTool = 'select';
document.querySelectorAll('.tool-btn')[0].click(); // Auto-Switch zu Select
setTool('select');
} }
} }
updateUI(); updateUI();
@@ -348,63 +291,117 @@
canvas.addEventListener('mousemove', e => { canvas.addEventListener('mousemove', e => {
if (isDragging && selectedElement) { if (isDragging && selectedElement) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const rawX = e.clientX - rect.left - dragStart.x; const mx = e.clientX - rect.left;
const rawY = e.clientY - rect.top - dragStart.y; const my = e.clientY - rect.top;
selectedElement.x = Math.floor(rawX / 10) * 10;
selectedElement.y = Math.floor(rawY / 10) * 10; selectedElement.x = Math.floor((mx - dragStart.x) / 10) * 10;
selectedElement.y = Math.floor((my - dragStart.y) / 10) * 10;
updateUI(); updateUI();
} }
}); });
window.addEventListener('mouseup', () => { isDragging = false; });
// --- UI UPDATES --- window.addEventListener('mouseup', () => isDragging = false);
function updateUI() {
const props = document.getElementById('props'); // --- 4. RENDERING ---
if(selectedElement) { function getImage(path) {
props.style.display = 'block'; if (!path) return null;
const set = (id, val) => document.getElementById(id).value = val; if (imageCache[path]) return imageCache[path];
set('prop-id', selectedElement.id || ''); const img = new Image();
set('prop-type', selectedElement.type || ''); img.src = "../assets/" + path;
set('prop-x', selectedElement.x); imageCache[path] = img;
set('prop-y', selectedElement.y); return img;
set('prop-w', selectedElement.w); }
set('prop-h', selectedElement.h);
set('prop-image', selectedElement.image || ''); function renderLoop() {
set('prop-scale', selectedElement.imgScale || 1.0); // Logische Größe (CSS Pixel)
set('prop-color', selectedElement.color || '#888888'); const w = canvas.width / dpr;
set('prop-imgx', selectedElement.imgOffsetX || 0); const h = canvas.height / dpr;
set('prop-imgy', selectedElement.imgOffsetY || 0); 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 { } else {
props.style.display = 'none'; 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() { function updateProp() {
if (!selectedElement) return; if (!selectedElement) return;
const get = (id) => document.getElementById(id).value; selectedElement.x = parseFloat(document.getElementById('prop-x').value);
const getNum = (id) => parseFloat(document.getElementById(id).value); selectedElement.y = parseFloat(document.getElementById('prop-y').value);
selectedElement.w = parseFloat(document.getElementById('prop-w').value);
selectedElement.id = get('prop-id'); selectedElement.h = parseFloat(document.getElementById('prop-h').value);
selectedElement.x = getNum('prop-x');
selectedElement.y = getNum('prop-y');
selectedElement.w = getNum('prop-w');
selectedElement.h = getNum('prop-h');
selectedElement.image = get('prop-image');
selectedElement.imgScale = getNum('prop-scale');
selectedElement.color = get('prop-color');
selectedElement.imgOffsetX = getNum('prop-imgx');
selectedElement.imgOffsetY = getNum('prop-imgy');
}
function setTool(t) {
currentTool = t;
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
const btns = document.querySelectorAll('.tool-btn');
for(let btn of btns) {
if(btn.innerText.toLowerCase().includes(t)) btn.classList.add('active');
}
selectedElement = null;
updateUI();
} }
function deleteSelected() { function deleteSelected() {
@@ -414,66 +411,45 @@
updateUI(); updateUI();
} }
// --- SAVE / LOAD (DB) --- // --- 6. SAVE / LOAD ---
async function refreshList() {
const sel = document.getElementById('chunk-select');
sel.innerHTML = "<option>Lade...</option>";
try {
const res = await fetch('/api/config');
loadedConfig = await res.json();
sel.innerHTML = "";
if(loadedConfig.chunks && loadedConfig.chunks.length > 0) {
loadedConfig.chunks.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.innerText = c.id + " (L=" + (c.totalWidth||'?') + ")";
sel.appendChild(opt);
});
} else { sel.innerHTML = "<option value=''>Keine Chunks</option>"; }
} catch(e) { console.error(e); }
}
function loadSelectedChunk() { function loadSelectedChunk() {
const id = document.getElementById('chunk-select').value; const id = document.getElementById('chunk-select').value;
if(!id || !loadedConfig) return; if (!id) return;
const chunk = loadedConfig.chunks.find(c => c.id === id); const chunk = loadedConfig.chunks.find(c => c.id === id);
if(!chunk) return; if (!chunk) return alert("Chunk nicht gefunden");
if(!confirm("Editor überschreiben?")) return;
if (!confirm("Editor leeren und Chunk laden?")) return;
document.getElementById('chunk-name').value = chunk.id; document.getElementById('chunk-name').value = chunk.id;
document.getElementById('chunk-width').value = chunk.totalWidth || 2000; document.getElementById('chunk-width').value = chunk.totalWidth || 2000;
elements = []; elements = [];
const merge = (item, typeDefault) => ({ const adder = (item, type) => {
type: item.type || typeDefault, elements.push({
id: item.id || 'obj', id: item.id || 'plat', // Plattformen haben oft keine ID
type: type,
x: item.x, y: item.y, w: item.w, h: item.h, x: item.x, y: item.y, w: item.w, h: item.h,
color: item.color || '#888', _def: TOOL_DEFS[item.id] // Verknüpfung wiederherstellen
image: item.image || '',
imgScale: item.imgScale || 1.0,
imgOffsetX: item.imgOffsetX || 0,
imgOffsetY: item.imgOffsetY || 0
}); });
};
if(chunk.platforms) chunk.platforms.forEach(p => elements.push(merge(p, 'platform'))); if (chunk.platforms) chunk.platforms.forEach(p => adder(p, 'platform'));
if(chunk.obstacles) chunk.obstacles.forEach(o => elements.push(merge(o, 'obstacle'))); if (chunk.obstacles) chunk.obstacles.forEach(o => adder(o, o.type)); // Type aus Objekt oder Fallback
selectedElement = null; selectedElement = null;
updateUI(); updateUI();
alert(`Chunk '${id}' geladen!`);
} }
async function saveChunk() { async function saveChunk() {
const name = document.getElementById('chunk-name').value; const name = document.getElementById('chunk-name').value;
const width = parseInt(document.getElementById('chunk-width').value) || 2000; const width = parseInt(document.getElementById('chunk-width').value) || 2000;
for(let el of elements) { // Sauberes JSON bauen (ohne _def Referenzen)
if(!el.id) { alert("Fehler: ID fehlt!"); return; }
}
const mapObj = (el) => ({ const mapObj = (el) => ({
id: el.id, type: el.type, x: el.x, y: el.y, w: el.w, h: el.h, color: el.color, id: el.id,
image: el.image, imgScale: el.imgScale, imgOffsetX: el.imgOffsetX, imgOffsetY: el.imgOffsetY type: el.type,
x: el.x, y: el.y, w: el.w, h: el.h
}); });
const data = { const data = {
@@ -485,17 +461,14 @@
try { try {
const res = await fetch('/api/admin/chunks', { const res = await fetch('/api/admin/chunks', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}); });
if(res.ok) { alert("Gespeichert!"); refreshList(); } if (res.ok) alert("✅ Chunk gespeichert!");
else alert("Fehler beim Speichern"); else alert("Fehler beim Speichern");
} catch(e) { alert("Netzwerkfehler"); } } catch(e) { alert("Netzwerkfehler"); }
} }
function exportJSON() {
// ... (Export Logic same as Save but to clipboard) ...
alert("Nutze Save (DB), Copy ist hier deaktiviert um Platz zu sparen.");
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,7 @@
package main package main
// shared/types.go (oder types.go im Hauptverzeichnis)
type ObstacleDef struct { type ObstacleDef struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@@ -7,12 +9,19 @@ type ObstacleDef struct {
Height float64 `json:"height"` Height float64 `json:"height"`
Color string `json:"color"` Color string `json:"color"`
Image string `json:"image"` Image string `json:"image"`
CanTalk bool `json:"canTalk"`
SpeechLines []string `json:"speechLines"` // Visuelle Anpassungen
YOffset float64 `json:"yOffset"`
ImgScale float64 `json:"imgScale"` ImgScale float64 `json:"imgScale"`
ImgOffsetX float64 `json:"imgOffsetX"` ImgOffsetX float64 `json:"imgOffsetX"`
ImgOffsetY float64 `json:"imgOffsetY"` ImgOffsetY float64 `json:"imgOffsetY"`
// Logik
CanTalk bool `json:"canTalk"`
SpeechLines []string `json:"speechLines"`
YOffset float64 `json:"yOffset"`
// NEU: Verhindert, dass dieses Objekt vom Zufallsgenerator ausgewählt wird
NoRandomSpawn bool `json:"noRandomSpawn"`
} }
type ChunkObstacle struct { type ChunkObstacle struct {

View File

@@ -288,6 +288,11 @@ func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstac
var pool []ObstacleDef var pool []ObstacleDef
for _, d := range defs { for _, d := range defs {
if d.NoRandomSpawn {
continue
}
if isBoss { if isBoss {
if d.ID == "principal" || d.ID == "trashcan" { if d.ID == "principal" || d.ID == "trashcan" {
pool = append(pool, d) pool = append(pool, d)