fix
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m43s
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m43s
This commit is contained in:
15
config.go
15
config.go
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
handlers.go
67
handlers.go
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
64
k8s/app.yaml
64
k8s/app.yaml
@@ -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
12
k8s/pvc.yaml
Normal 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
|
||||||
5
main.go
5
main.go
@@ -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
449
secure/assets.html
Normal 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>
|
||||||
@@ -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>
|
||||||
15
types.go
15
types.go
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user