Private
Public Access
1
0

Merge pull request 'fix-game' (#17) from fix-debug into main
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m27s

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2025-11-29 22:47:43 +00:00
44 changed files with 3002 additions and 879 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ COPY static/js/ ./js/
# 1. Zusammenfügen (Reihenfolge ist wichtig!)
# 2. In IIFE (Function) wickeln für Kapselung (Sicherheit)
# 3. Minifizieren (Unlesbar machen)
RUN cat js/config.js js/state.js js/network.js js/input.js js/logic.js js/render.js js/main.js > temp.js \
RUN cat js/config.js js/state.js js/audio.js js/particles.js js/network.js js/input.js js/logic.js js/render.js js/main.js > temp.js \
&& echo "(function(){" > combined.js \
&& cat temp.js >> combined.js \
&& echo "})();" >> combined.js \
+35 -11
View File
@@ -2,20 +2,22 @@ package main
import (
"context"
"encoding/json"
"log"
"os"
"sort"
"github.com/redis/go-redis/v9"
)
const (
Gravity = 0.6
JumpPower = -12.0
HighJumpPower = -16.0
Gravity = 1.8
JumpPower = -20.0
HighJumpPower = -28.0
GroundY = 350.0
PlayerHeight = 50.0
PlayerYBase = GroundY - PlayerHeight
BaseSpeed = 5.0
BaseSpeed = 15.0
GameWidth = 800.0
)
@@ -39,17 +41,17 @@ func initGameConfig() {
defaultConfig = GameConfig{
Obstacles: []ObstacleDef{
// --- HINDERNISSE ---
{ID: "desk", Type: "obstacle", Width: 40, Height: 30, Color: "#8B4513", Image: "desk1.png"},
{ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}},
{ID: "desk", Type: "obstacle", Width: 50, Height: 65, Color: "#ff0000", Image: "desk.png", YOffset: -19, ImgScale: 1.3, ImgOffsetX: 1, ImgOffsetY: 3}, // desk
{ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png"},
{ID: "k-m", Type: "teacher", Width: 45, Height: 80, Color: "#ff0000", Image: "k-m.png", YOffset: 5, ImgScale: 1.2, ImgOffsetX: -1, ImgOffsetY: 8}, // k-m
{ID: "w-l", Type: "teacher", Width: 50, Height: 70, Color: "#ff0000", Image: "w-l.png", ImgScale: 1.1, ImgOffsetX: 1, ImgOffsetY: 3, CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}}, // w-l
{ID: "trashcan", Type: "obstacle", Width: 25, Height: 35, Color: "#555", Image: "trash1.png"},
{ID: "eraser", Type: "obstacle", Width: 30, Height: 20, Color: "#fff", Image: "eraser1.png", YOffset: 30.0},
{ID: "eraser1", Type: "obstacle", Width: 56, Height: 37, Color: "#ff0000", Image: "eraser.png", YOffset: 35, ImgScale: 1.6, ImgOffsetY: 9}, // eraser1
{ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal1.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}},
// --- COINS ---
{ID: "coin0", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 40.0},
{ID: "coin1", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 50.0},
{ID: "coin2", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 60.0},
{ID: "coin0", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", ImgScale: 1.1, ImgOffsetY: 1},
{ID: "coin1", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", YOffset: 60, ImgScale: 1.1, ImgOffsetY: 1},
// --- POWERUPS ---
{ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god1.png", YOffset: 20.0}, // Godmode
@@ -60,4 +62,26 @@ func initGameConfig() {
Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"},
}
log.Println("✅ Config mit Powerups geladen")
loadChunksFromRedis()
}
func loadChunksFromRedis() {
// Gleiche Logik wie im Handler, aber speichert es in die globale Variable
if rdb == nil {
return
} // Falls Redis noch nicht da ist
ids, _ := rdb.SMembers(ctx, "config:chunks:list").Result()
sort.Strings(ids) // WICHTIG
var chunks []ChunkDef
for _, id := range ids {
val, _ := rdb.Get(ctx, "config:chunks:data:"+id).Result()
var c ChunkDef
json.Unmarshal([]byte(val), &c)
chunks = append(chunks, c)
}
defaultConfig.Chunks = chunks
log.Printf("📦 %d Custom Chunks geladen", len(chunks))
}
+6 -2
View File
@@ -2,9 +2,13 @@ module escape-teacher
go 1.25.4
require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.17.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/redis/go-redis/v9 v9.17.0 // indirect
)
+6
View File
@@ -1,8 +1,14 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
+50 -4
View File
@@ -6,6 +6,7 @@ import (
"log"
"math/rand"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -14,9 +15,54 @@ import (
"github.com/redis/go-redis/v9"
)
func handleEditorPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./secure/editor.html")
}
func handleObstacleEditorPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./secure/obstacle_editor.html")
}
func handleAdminChunks(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var chunk ChunkDef
if err := json.NewDecoder(r.Body).Decode(&chunk); err != nil {
http.Error(w, "Bad JSON", 400)
return
}
chunkJson, _ := json.Marshal(chunk)
rdb.SAdd(ctx, "config:chunks:list", chunk.ID)
rdb.Set(ctx, "config:chunks:data:"+chunk.ID, chunkJson, 0)
w.WriteHeader(http.StatusOK)
}
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
conf := defaultConfig
chunkIDs, _ := rdb.SMembers(ctx, "config:chunks:list").Result()
sort.Strings(chunkIDs)
var loadedChunks []ChunkDef
// 3. Details laden
for _, id := range chunkIDs {
val, err := rdb.Get(ctx, "config:chunks:data:"+id).Result()
if err == nil {
var c ChunkDef
json.Unmarshal([]byte(val), &c)
c.ID = id // Sicherstellen, dass ID gesetzt ist
loadedChunks = append(loadedChunks, c)
}
}
conf.Chunks = loadedChunks
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(defaultConfig)
json.NewEncoder(w).Encode(conf)
}
func handleStart(w http.ResponseWriter, r *http.Request) {
@@ -64,9 +110,7 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
return
}
// ---> HIER RUFEN WIR JETZT DIE SIMULATION AUF <---
isDead, score, obstacles, powerUpState, serverTick, nextSpawnTick := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
isDead, score, obstacles, platforms, powerUpState, serverTick, nextSpawnTick, finalRngState := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
status := "alive"
if isDead {
status = "dead"
@@ -78,9 +122,11 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
Status: status,
VerifiedScore: score,
ServerObs: obstacles,
ServerPlats: platforms,
PowerUps: powerUpState,
ServerTick: serverTick,
NextSpawnTick: nextSpawnTick,
RngState: finalRngState,
})
}
+4
View File
@@ -39,6 +39,7 @@ func main() {
http.HandleFunc("/api/config", Logger(handleConfig))
http.HandleFunc("/api/start", Logger(handleStart))
http.HandleFunc("/api/validate", Logger(handleValidate))
http.HandleFunc("/ws", handleWebSocket)
http.HandleFunc("/api/submit-name", Logger(handleSubmitName))
http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard))
http.HandleFunc("/api/claim/delete", Logger(handleClaimDelete))
@@ -48,6 +49,9 @@ func main() {
http.HandleFunc("/api/admin/badwords", Logger(BasicAuth(handleAdminBadwords)))
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))
http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction)))
http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks)))
http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage)))
http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage)))
log.Println("🦖 Server läuft auf :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
+501
View File
@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Ultimate Chunk Editor (+Templates)</title>
<style>
body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; }
#sidebar {
width: 340px; background: #2a2a2a; padding: 15px; border-right: 2px solid #444;
display: flex; flex-direction: column; gap: 10px; overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.5);
}
#canvas-wrapper {
flex: 1; overflow: auto; position: relative; background: #333;
background-image: linear-gradient(45deg, #252525 25%, transparent 25%), linear-gradient(-45deg, #252525 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #252525 75%), linear-gradient(-45deg, transparent 75%, #252525 75%);
background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
canvas { background: rgba(0,0,0,0.2); cursor: crosshair; display: block; margin-top: 50px; }
.tool-btn { padding: 8px; background: #444; border: 1px solid #555; color: white; cursor: pointer; text-align: left; border-radius: 4px; font-size: 12px; }
.tool-btn.active { background: #ffcc00; color: black; font-weight: bold; border-color: #ffcc00; }
.tool-btn:hover:not(.active) { background: #555; }
.panel { background: #333; padding: 10px; border: 1px solid #444; border-radius: 4px; margin-bottom: 5px; }
.group-title { font-size: 10px; color: #2196F3; font-weight: bold; margin-bottom: 5px; text-transform: uppercase; border-bottom: 1px solid #444; padding-bottom: 2px; }
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; }
.row { display: flex; gap: 5px; }
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;}
button.action { width:100%; border:none; color:white; cursor:pointer; padding: 8px; margin-top:5px; border-radius:3px; font-weight:bold;}
/* Template Liste Style */
.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; }
.template-item:hover { background: #444; }
.template-del { color: #f44336; font-weight: bold; padding: 0 5px; cursor: pointer; }
</style>
</head>
<body>
<div id="sidebar">
<h3>🎨 VORLAGEN</h3>
<div class="panel">
<div style="display:flex; gap:5px;">
<input type="text" id="tpl-name" placeholder="Name (z.B. Teacher Hut)" style="margin:0;">
<button onclick="saveTemplate()" style="background:#4caf50; border:none; color:white; cursor:pointer; width:40px;">💾</button>
</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>
<h3>🛠 TOOLS</h3>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:5px;">
<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('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>
<h3>⚙️ EIGENSCHAFTEN</h3>
<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" 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 style="flex:1"><label>X</label><input type="number" id="prop-x" oninput="updateProp()"></div>
<div style="flex:1"><label>Y</label><input type="number" id="prop-y" oninput="updateProp()"></div>
</div>
<div class="row">
<div style="flex:1"><label>Width</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>
<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 style="margin-top:auto;">
<h3>💾 LEVEL DATEI</h3>
<div class="panel">
<select id="chunk-select"><option value="">Lade Liste...</option></select>
<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>
<input type="text" id="chunk-name" value="level_01">
<label>Länge</label>
<input type="number" id="chunk-width" value="2000">
<button class="action" onclick="saveChunk()" style="background:#4caf50;">💾 Speichern (DB)</button>
<button class="action" onclick="exportJSON()" style="background:#FF9800;">📋 JSON Copy</button>
</div>
</div>
</div>
<div id="canvas-wrapper">
<canvas id="editorCanvas" width="4000" height="500"></canvas>
</div>
<script>
const canvas = document.getElementById('editorCanvas');
const ctx = canvas.getContext('2d');
// State
let elements = [];
let selectedElement = null;
let currentTool = 'select';
let isDragging = false;
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 ---
renderTemplateList(); // Templates laden
refreshList();
requestAnimationFrame(renderLoop);
// --- HELPER ---
function getImage(path) {
if (!path) return null;
if (imageCache[path]) return imageCache[path];
const img = new Image();
img.src = "../../assets/" + path;
img.onerror = () => { img.src = "../../assets/" + path; };
imageCache[path] = img;
return img;
}
function renderLoop() { draw(); requestAnimationFrame(renderLoop); }
function draw() {
ctx.clearRect(0,0,canvas.width, canvas.height);
// Grid
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0, 350, canvas.width, 50);
ctx.strokeStyle = "#aaa"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 350); ctx.lineTo(canvas.width, 350); ctx.stroke();
// Start Zone
ctx.fillStyle = "rgba(0,255,0,0.05)"; ctx.fillRect(0, 0, 800, 350);
ctx.fillStyle = "#aaa"; ctx.font = "12px Arial"; ctx.fillText("Viewport Start (800px)", 10, 20);
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
ctx.fillStyle = "white"; ctx.font = "bold 10px sans-serif";
ctx.shadowColor="black"; ctx.shadowBlur=3;
ctx.fillText(el.id, x, y - 4);
ctx.shadowBlur=0;
});
}
// --- INTERACTION ---
canvas.addEventListener('mousedown', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (currentTool === 'select') {
selectedElement = null;
for(let i=elements.length-1; i>=0; i--) {
const el = elements[i];
if(mx >= el.x && mx <= el.x + el.w && my >= el.y && my <= el.y + el.h) {
selectedElement = el;
isDragging = true;
dragStart = { x: mx - el.x, y: my - el.y };
break;
}
}
} else {
const def = DEFAULTS[currentTool];
if(def) {
const newEl = {
type: def.type, id: def.id,
x: Math.floor(mx/10)*10, y: Math.floor(my/10)*10,
w: def.w, h: def.h, color: def.color,
image: def.image,
imgScale: 1.0, imgOffsetX: 0, imgOffsetY: 0
};
// --- 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);
selectedElement = newEl;
currentTool = 'select';
document.querySelectorAll('.tool-btn')[0].click();
}
}
updateUI();
});
canvas.addEventListener('mousemove', e => {
if(isDragging && selectedElement) {
const rect = canvas.getBoundingClientRect();
const rawX = e.clientX - rect.left - dragStart.x;
const rawY = e.clientY - rect.top - dragStart.y;
selectedElement.x = Math.floor(rawX / 10) * 10;
selectedElement.y = Math.floor(rawY / 10) * 10;
updateUI();
}
});
window.addEventListener('mouseup', () => { isDragging = false; });
// --- UI UPDATES ---
function updateUI() {
const props = document.getElementById('props');
if(selectedElement) {
props.style.display = 'block';
const set = (id, val) => document.getElementById(id).value = val;
set('prop-id', selectedElement.id || '');
set('prop-type', selectedElement.type || '');
set('prop-x', selectedElement.x);
set('prop-y', selectedElement.y);
set('prop-w', selectedElement.w);
set('prop-h', selectedElement.h);
set('prop-image', selectedElement.image || '');
set('prop-scale', selectedElement.imgScale || 1.0);
set('prop-color', selectedElement.color || '#888888');
set('prop-imgx', selectedElement.imgOffsetX || 0);
set('prop-imgy', selectedElement.imgOffsetY || 0);
} else {
props.style.display = 'none';
}
}
function updateProp() {
if(!selectedElement) return;
const get = (id) => document.getElementById(id).value;
const getNum = (id) => parseFloat(document.getElementById(id).value);
selectedElement.id = get('prop-id');
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() {
if(!selectedElement) return;
elements = elements.filter(e => e !== selectedElement);
selectedElement = null;
updateUI();
}
// --- SAVE / LOAD (DB) ---
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() {
const id = document.getElementById('chunk-select').value;
if(!id || !loadedConfig) return;
const chunk = loadedConfig.chunks.find(c => c.id === id);
if(!chunk) return;
if(!confirm("Editor überschreiben?")) return;
document.getElementById('chunk-name').value = chunk.id;
document.getElementById('chunk-width').value = chunk.totalWidth || 2000;
elements = [];
const merge = (item, typeDefault) => ({
type: item.type || typeDefault,
id: item.id || 'obj',
x: item.x, y: item.y, w: item.w, h: item.h,
color: item.color || '#888',
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.obstacles) chunk.obstacles.forEach(o => elements.push(merge(o, 'obstacle')));
selectedElement = null;
updateUI();
alert(`Chunk '${id}' geladen!`);
}
async function saveChunk() {
const name = document.getElementById('chunk-name').value;
const width = parseInt(document.getElementById('chunk-width').value) || 2000;
for(let el of elements) {
if(!el.id) { alert("Fehler: ID fehlt!"); return; }
}
const mapObj = (el) => ({
id: el.id, type: el.type, x: el.x, y: el.y, w: el.w, h: el.h, color: el.color,
image: el.image, imgScale: el.imgScale, imgOffsetX: el.imgOffsetX, imgOffsetY: el.imgOffsetY
});
const data = {
id: name,
totalWidth: width,
platforms: elements.filter(e => e.type === 'platform').map(mapObj),
obstacles: elements.filter(e => e.type !== 'platform').map(mapObj)
};
try {
const res = await fetch('/api/admin/chunks', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
});
if(res.ok) { alert("Gespeichert!"); refreshList(); }
else alert("Fehler beim Speichern");
} 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>
</body>
</html>
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Obstacle Tuner (Realtime)</title>
<style>
body { background: #1a1a1a; color: #ddd; font-family: 'Segoe UI', monospace; display: flex; height: 100vh; margin: 0; overflow: hidden; }
/* Sidebar */
#controls {
width: 360px; background: #2a2a2a; padding: 15px; border-right: 2px solid #444;
display: flex; flex-direction: column; gap: 10px; overflow-y: auto;
box-shadow: 5px 0 15px rgba(0,0,0,0.5);
}
/* Canvas Area */
#preview {
flex: 1; background-color: #333;
/* Schachbrettmuster */
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;
display: flex; align-items: center; justify-content: center; position: relative;
}
canvas { border: 2px solid #555; background: rgba(0,0,0,0.3); box-shadow: 0 0 20px rgba(0,0,0,0.8); cursor: grab; }
canvas:active { cursor: grabbing; }
/* Controls */
.group { background: #333; padding: 8px; border-radius: 6px; border: 1px solid #444; }
.group-title { font-size: 10px; color: #ffcc00; font-weight: bold; margin-bottom: 5px; text-transform: uppercase; border-bottom: 1px solid #444; padding-bottom: 2px; display:flex; justify-content:space-between;}
label { display: block; color: #888; font-size: 10px; margin-bottom: 1px; }
input, select, textarea {
width: 100%; box-sizing: border-box; background: #1a1a1a; border: 1px solid #555;
color: #fff; padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 12px;
}
input:focus { outline: none; border-color: #ffcc00; }
input[type=range] { padding: 0; margin: 5px 0; cursor: pointer; }
.row { display: flex; gap: 8px; align-items: center; }
h2 { margin: 0 0 10px 0; color: #ffcc00; border-bottom: 1px solid #555; padding-bottom: 10px; font-size: 18px; }
#output { height: 60px; font-size: 10px; color: #aaddff; resize: vertical; margin-top: auto;}
button { background: #4caf50; color: white; border: none; padding: 8px; font-weight: bold; cursor: pointer; border-radius: 4px; width: 100%; font-size: 12px; }
button:hover { background: #45a049; }
.btn-copy { background: #2196F3; margin-top: 5px; }
/* Highlight wenn Dragging */
.drag-active { border: 1px solid #2196F3 !important; background: #2a3a4a !important; }
</style>
</head>
<body>
<div id="controls">
<h2>🛠 OBSTACLE TUNER</h2>
<div class="group">
<div class="group-title">Basis</div>
<div class="row">
<div style="flex:2"><label>ID</label><input type="text" id="inp-id" value="new_item" oninput="update()"></div>
<div style="flex:1"><label>Type</label>
<select id="inp-type" oninput="update()">
<option value="obstacle">Obstacle</option>
<option value="teacher">Teacher</option>
<option value="coin">Coin</option>
<option value="powerup">PowerUp</option>
</select>
</div>
</div>
<label style="margin-top:5px">Bild Datei (../../assets/)</label>
<input type="text" id="inp-image" value="teacher1.png" oninput="loadImage()">
</div>
<div class="group">
<div class="group-title">🔴 Hitbox (Physik)</div>
<div class="row">
<div style="flex:1"><label>Breite</label><input type="number" id="inp-w" value="30" oninput="update()"></div>
<div style="flex:1"><label>Höhe</label><input type="number" id="inp-h" value="60" oninput="update()"></div>
</div>
<input type="range" min="10" max="200" value="30" id="slider-w" oninput="syncSlider('w')">
<input type="range" min="10" max="200" value="60" id="slider-h" oninput="syncSlider('h')">
<label>Y-Offset (Schweben)</label>
<div class="row">
<input type="number" id="inp-yoff" value="0" oninput="update()">
<input type="range" min="-50" max="100" value="0" step="5" id="slider-yoff" oninput="syncSlider('yoff')">
</div>
</div>
<div class="group" style="border-color: #2196F3;" id="grp-visuals">
<div class="group-title" style="color:#2196F3">
<span>🖼️ Optik (Textur)</span>
<small style="font-weight:normal; font-size:9px; color:#aaa;">Drag Canvas to Move</small>
</div>
<label>Scale (Größe)</label>
<div class="row">
<input type="number" id="inp-scale" value="1.0" step="0.1" oninput="syncInput('scale')">
<input type="range" min="0.5" max="3.0" step="0.1" value="1.0" id="slider-scale" oninput="syncSlider('scale')">
</div>
<div class="row" style="margin-top:5px;">
<div style="flex:1"><label>Offset X</label><input type="number" id="inp-imgx" value="0" oninput="update()"></div>
<div style="flex:1"><label>Offset Y</label><input type="number" id="inp-imgy" value="0" oninput="update()"></div>
</div>
<button onclick="resetVisuals()" style="background:#555; margin-top:5px; padding:4px; font-size:10px;">Reset Optik</button>
</div>
<div class="group">
<label>Fallback Farbe</label>
<input type="color" id="inp-color" value="#ff0000" style="height:25px; padding:0;" oninput="update()">
</div>
<textarea id="output" readonly onclick="this.select()"></textarea>
<button class="btn-copy" onclick="copyToClipboard()">📋 GO STRING KOPIEREN</button>
</div>
<div id="preview">
<canvas id="canvas" width="600" height="400"></canvas>
<div style="position:absolute; bottom: 10px; right: 10px; color:white; font-size:10px; text-align:right; pointer-events:none;">
<span style="color:#2196F3; font-weight:bold;">Maus Drag: Bild verschieben</span><br>
Grün = Spieler (Referenz)<br>Rot = Hitbox
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Konstanten
const GROUND_Y = 300;
const PLAYER = { w: 30, h: 50, color: '#33cc33' };
// Bild Cache
let imgObj = new Image();
let imgLoaded = false;
// Dragging Status
let isDragging = false;
let dragStart = {x:0, y:0};
let startOffset = {x:0, y:0};
// --- INIT ---
loadImage(); // Lädt Bild und startet Draw Loop
// --- EVENTS & LOGIC ---
// 1. Slider <-> Input Sync (Bi-Direktional)
function syncSlider(id) {
document.getElementById('inp-'+id).value = document.getElementById('slider-'+id).value;
update();
}
function syncInput(id) {
document.getElementById('slider-'+id).value = document.getElementById('inp-'+id).value;
update();
}
// 2. Reset Button
function resetVisuals() {
document.getElementById('inp-scale').value = 1.0;
document.getElementById('slider-scale').value = 1.0;
document.getElementById('inp-imgx').value = 0;
document.getElementById('inp-imgy').value = 0;
update();
}
// 3. Bild Laden (Nur wenn Pfad sich ändert!)
function loadImage() {
const path = document.getElementById('inp-image').value;
imgLoaded = false;
imgObj = new Image();
// Pfad-Logik für Local vs Server
imgObj.src = "../../assets/" + path;
imgObj.onload = () => {
imgLoaded = true;
draw(); // Sofort neu zeichnen
};
imgObj.onerror = () => {
// Fallback Pfad probieren
imgObj.src = "../../assets/" + path;
};
draw();
}
// 4. Zentrales Update (Alles außer Bildpfad)
function update() {
// Hier wird NICHT das Bild neu geladen, nur Parameter gelesen
draw();
generateString();
}
// --- DRAG & DROP LOGIK ---
canvas.addEventListener('mousedown', e => {
isDragging = true;
dragStart = { x: e.clientX, y: e.clientY };
// Aktuelle Werte holen
startOffset = {
x: parseFloat(document.getElementById('inp-imgx').value) || 0,
y: parseFloat(document.getElementById('inp-imgy').value) || 0
};
document.getElementById('grp-visuals').classList.add('drag-active');
canvas.style.cursor = "grabbing";
});
window.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
// Neue Werte berechnen (Integer reichen meist für Pixel Art)
const newX = Math.round(startOffset.x + dx);
const newY = Math.round(startOffset.y + dy);
// Inputs updaten (ohne draw aufzurufen, das machen wir direkt)
document.getElementById('inp-imgx').value = newX;
document.getElementById('inp-imgy').value = newY;
update(); // Löst Draw & String Gen aus
});
window.addEventListener('mouseup', () => {
if(isDragging) {
isDragging = false;
document.getElementById('grp-visuals').classList.remove('drag-active');
canvas.style.cursor = "grab";
}
});
// --- RENDERING ---
function draw() {
// Canvas leeren
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 1. BODEN
ctx.fillStyle = "#222";
ctx.fillRect(0, GROUND_Y, canvas.width, 2);
// 2. SPIELER (DUMMY)
const pX = 200;
const pY = GROUND_Y - PLAYER.h;
ctx.fillStyle = PLAYER.color;
ctx.fillRect(pX, pY, PLAYER.w, PLAYER.h);
ctx.fillStyle = "white"; ctx.font = "10px sans-serif"; ctx.fillText("PLAYER", pX, pY - 5);
// 3. PARAMETER LESEN
const w = parseFloat(document.getElementById('inp-w').value) || 30;
const h = 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 imgX = parseFloat(document.getElementById('inp-imgx').value) || 0;
const imgY = parseFloat(document.getElementById('inp-imgy').value) || 0;
const color = document.getElementById('inp-color').value;
const id = document.getElementById('inp-id').value;
// Position des Objekts (300px = Rechts vom Spieler)
const oX = 300;
const oY = GROUND_Y - h - yOff;
// A. HITBOX ZEICHNEN (Unter dem Bild)
ctx.fillStyle = "rgba(255, 0, 0, 0.25)"; // Halbtransparent
ctx.fillRect(oX, oY, w, h);
ctx.strokeStyle = "#ff0000"; ctx.lineWidth = 2; ctx.setLineDash([]);
ctx.strokeRect(oX, oY, w, h);
// Label
ctx.fillStyle = "#ff5555";
ctx.fillText(`HITBOX ${w}x${h}`, oX, oY - 5);
// B. BILD ZEICHNEN
if (imgLoaded) {
const drawW = w * scale;
const drawH = h * scale;
// Logik wie im Spiel:
// 1. Zentrieren auf Hitbox
const centerX = oX + (w - drawW) / 2;
// 2. Unten Bündig
const bottomY = oY + (h - drawH);
// 3. Offsets anwenden
const finalX = centerX + imgX;
const finalY = bottomY + imgY;
// Zeichnen
ctx.drawImage(imgObj, finalX, finalY, drawW, drawH);
// Blauer Rahmen um Textur
ctx.strokeStyle = "#2196F3"; ctx.lineWidth = 1; ctx.setLineDash([4, 2]);
ctx.strokeRect(finalX, finalY, drawW, drawH);
// Verbindungslinie (Center Hitbox -> Center Bild)
ctx.beginPath();
ctx.moveTo(oX + w/2, oY + h/2);
ctx.lineTo(finalX + drawW/2, finalY + drawH/2);
ctx.strokeStyle = "rgba(33, 150, 243, 0.4)";
ctx.stroke();
} else {
// Fallback Grafik
ctx.fillStyle = color;
ctx.fillRect(oX, oY, w, h);
ctx.fillStyle = "white";
ctx.fillText("IMG FEHLT", oX+2, oY + h/2);
}
}
// --- EXPORT ---
function generateString() {
const id = document.getElementById('inp-id').value;
const type = document.getElementById('inp-type').value;
const w = document.getElementById('inp-w').value;
const h = document.getElementById('inp-h').value;
const color = document.getElementById('inp-color').value;
const img = document.getElementById('inp-image').value;
const yOff = parseFloat(document.getElementById('inp-yoff').value);
const scale = parseFloat(document.getElementById('inp-scale').value);
const imgX = parseFloat(document.getElementById('inp-imgx').value);
const imgY = parseFloat(document.getElementById('inp-imgy').value);
// String bauen
let str = `{ID: "${id}", Type: "${type}", Width: ${w}, Height: ${h}, Color: "${color}", Image: "${img}"`;
if (yOff !== 0) str += `, YOffset: ${yOff}`;
if (scale !== 1.0) str += `, ImgScale: ${scale}`;
if (imgX !== 0) str += `, ImgOffsetX: ${imgX}`;
if (imgY !== 0) str += `, ImgOffsetY: ${imgY}`;
str += `}, // ${id}`;
document.getElementById('output').value = str;
}
function copyToClipboard() {
const copyText = document.getElementById("output");
copyText.select();
navigator.clipboard.writeText(copyText.value);
const btn = document.querySelector('.btn-copy');
const oldText = btn.innerText;
btn.innerText = "✅ KOPIERT!";
btn.style.background = "#2e7d32";
setTimeout(() => {
btn.innerText = oldText;
btn.style.background = "#2196F3";
}, 1000);
}
</script>
</body>
</html>
+465 -247
View File
@@ -8,273 +8,491 @@ import (
"strconv"
)
func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, PowerUpState, int, int) {
posY := parseOr(vals["pos_y"], PlayerYBase)
velY := parseOr(vals["vel_y"], 0.0)
score := int(parseOr(vals["score"], 0))
ticksAlive := int(parseOr(vals["total_ticks"], 0))
rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64)
// --- INTERNE STATE STRUKTUR ---
type SimState struct {
SessionID string
Score int
Ticks int
PosY float64
VelY float64
IsDead bool
nextSpawnTick := int(parseOr(vals["next_spawn_tick"], 0))
// Objekte
Obstacles []ActiveObstacle
Platforms []ActivePlatform
godLives := int(parseOr(vals["p_god_lives"], 0))
hasBat := vals["p_has_bat"] == "1"
bootTicks := int(parseOr(vals["p_boot_ticks"], 0))
// Powerups
GodLives int
HasBat bool
BootTicks int
lastJumpDist := parseOr(vals["ac_last_dist"], 0.0)
suspicionScore := int(parseOr(vals["ac_suspicion"], 0))
// Spawning & RNG
NextSpawnTick int
RNG *PseudoRNG
rng := NewRNG(rngStateVal)
// Anti-Cheat
LastJumpDist float64
SuspicionScore int
}
var obstacles []ActiveObstacle
if val, ok := vals["obstacles"]; ok && val != "" {
json.Unmarshal([]byte(val), &obstacles)
} else {
obstacles = []ActiveObstacle{}
// ============================================================================
// HAUPTFUNKTION
// ============================================================================
func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, []ActivePlatform, PowerUpState, int, int, uint32) {
// 1. State laden
state := loadSimState(sessionID, vals)
// 2. Bot-Check
if isBotSpamming(inputs) {
log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge", sessionID)
state.IsDead = true
return packResponse(&state)
}
// 3. Game Loop
for i := 0; i < totalTicks; i++ {
state.Ticks++
currentSpeed := calculateSpeed(state.Ticks)
didJump, isCrouching := parseInput(inputs, i)
updatePhysics(&state, didJump, isCrouching, currentSpeed)
checkCollisions(&state, isCrouching, currentSpeed)
if state.IsDead {
break
}
moveWorld(&state, currentSpeed)
handleSpawning(&state, currentSpeed)
state.Score++
}
// 4. Anti-Cheat Heuristik
if state.SuspicionScore > 15 {
log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID)
state.IsDead = true
}
// 5. Speichern
saveSimState(&state)
return packResponse(&state)
}
// ============================================================================
// LOGIK & PHYSIK FUNKTIONEN
// ============================================================================
func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) {
jumpPower := JumpPower
if s.BootTicks > 0 {
jumpPower = HighJumpPower
s.BootTicks--
}
isGrounded := checkGrounded(s)
// Fehler behoben: "currentHeight declared but not used" entfernt.
// Wir brauchen es hier nicht, da checkPlatformLanding mit fixen 50.0 rechnet.
// Die Hitbox-Änderung passiert nur in checkCollisions.
if isCrouching && !isGrounded {
s.VelY += 2.0 // Fast Fall
}
if didJump && isGrounded && !isCrouching {
s.VelY = jumpPower
isGrounded = false
checkJumpSuspicion(s)
}
s.VelY += Gravity
oldY := s.PosY
newY := s.PosY + s.VelY
landed := false
// A. Plattform Landung (One-Way Logic)
if s.VelY > 0 {
for _, p := range s.Platforms {
hit, landY := checkPlatformLanding(p.X, p.Y, p.Width, 50.0, oldY, newY, s.VelY)
if hit {
newY = landY
s.VelY = 0
landed = true
break
}
}
}
// B. Boden Landung
if !landed {
if newY >= PlayerYBase {
newY = PlayerYBase
s.VelY = 0
}
}
s.PosY = newY
}
func checkCollisions(s *SimState, isCrouching bool, currentSpeed float64) {
hitboxH := PlayerHeight
hitboxY := s.PosY
if isCrouching {
hitboxH = PlayerHeight / 2
hitboxY = s.PosY + (PlayerHeight - hitboxH)
}
activeObs := []ActiveObstacle{}
for _, obs := range s.Obstacles {
// Passed Check
paddingX := 10.0
if obs.X+obs.Width-paddingX < 55.0 {
activeObs = append(activeObs, obs)
continue
}
pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX
pTop, pBottom := hitboxY+10.0, hitboxY+hitboxH-5.0
if obs.Type == "teacher" {
pTop = hitboxY + 25.0
}
oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX
oTop, oBottom := obs.Y+10.0, obs.Y+obs.Height-5.0
isHit := pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom
if isHit {
if obs.Type == "coin" {
s.Score += 2000
continue
}
if obs.Type == "powerup" {
applyPowerup(s, obs.ID)
continue
}
if s.HasBat && obs.Type == "teacher" {
s.HasBat = false
log.Printf("[%s] ⚾ Bat used on %s", s.SessionID, obs.ID)
continue
}
if s.GodLives > 0 {
s.GodLives--
log.Printf("[%s] 🛡️ Godmode saved life", s.SessionID)
continue
}
_, pRight := 50.0+10.0, 50.0+30.0-10.0 // Player X ist fest
// Player Y/Height ausrechnen (für Log)
pH := PlayerHeight
if isCrouching {
pH = PlayerHeight / 2
}
pTopLog := s.PosY + 10.0
pBottomLog := s.PosY + pH - 5.0
log.Printf("\n💀 --- DEATH REPORT [%s] ---\n"+
"⏱️ Tick: %d (Speed: %.2f)\n"+
"🏃 Player: Y=%.2f (Top: %.2f, Bottom: %.2f) | VelY=%.2f | Duck=%v\n"+
"🧱 Killer: ID='%s' (Type=%s)\n"+
"📍 Object: X=%.2f (Left: %.2f, Right: %.2f)\n"+
" Y=%.2f (Top: %.2f, Bottom: %.2f)\n"+
"💥 Overlap: X-Diff=%.2f, Y-Diff=%.2f\n"+
"------------------------------------------\n",
s.SessionID,
s.Ticks, currentSpeed,
s.PosY, pTopLog, pBottomLog, s.VelY, isCrouching,
obs.ID, obs.Type,
obs.X, oLeft, oRight,
obs.Y, oTop, oBottom,
(oLeft - pRight), (oTop - pBottomLog), // Wenn negativ, überlappen sie
)
log.Printf("💀 DEATH [%s]: Hit %s at Tick %d", s.SessionID, obs.ID, s.Ticks)
s.IsDead = true
}
activeObs = append(activeObs, obs)
}
s.Obstacles = activeObs
}
func moveWorld(s *SimState, speed float64) {
nextObs := []ActiveObstacle{}
for _, o := range s.Obstacles {
o.X -= speed
if o.X+o.Width > -200.0 {
nextObs = append(nextObs, o)
}
}
s.Obstacles = nextObs
nextPlats := []ActivePlatform{}
for _, p := range s.Platforms {
p.X -= speed
if p.X+p.Width > -200.0 {
nextPlats = append(nextPlats, p)
}
}
s.Platforms = nextPlats
}
func handleSpawning(s *SimState, speed float64) {
if s.NextSpawnTick == 0 {
s.NextSpawnTick = s.Ticks + 50
}
if s.Ticks >= s.NextSpawnTick {
spawnX := GameWidth + 3200.0
// --- OPTION A: CUSTOM CHUNK (20% Chance) ---
chunkCount := len(defaultConfig.Chunks)
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
chunk := defaultConfig.Chunks[idx]
// Objekte spawnen
for _, p := range chunk.Platforms {
s.Platforms = append(s.Platforms, ActivePlatform{
X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height,
})
}
for _, o := range chunk.Obstacles {
// Fehler behoben: Zugriff auf o.X, o.Y jetzt möglich dank neuem Types-Struct
s.Obstacles = append(s.Obstacles, ActiveObstacle{
ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height,
})
}
width := chunk.TotalWidth
if width == 0 {
width = 2000
}
// Fehler behoben: Mismatched Types (int vs float64)
s.NextSpawnTick = s.Ticks + int(float64(width)/speed)
} else {
// --- OPTION B: RANDOM GENERATION ---
spawnRandomObstacle(s, speed, spawnX)
}
}
}
// ============================================================================
// HELPER
// ============================================================================
func loadSimState(sid string, vals map[string]string) SimState {
rngState, _ := strconv.ParseInt(vals["rng_state"], 10, 64)
s := SimState{
SessionID: sid,
Score: int(parseOr(vals["score"], 0)),
Ticks: int(parseOr(vals["total_ticks"], 0)),
PosY: parseOr(vals["pos_y"], PlayerYBase),
VelY: parseOr(vals["vel_y"], 0.0),
NextSpawnTick: int(parseOr(vals["next_spawn_tick"], 0)),
GodLives: int(parseOr(vals["p_god_lives"], 0)),
BootTicks: int(parseOr(vals["p_boot_ticks"], 0)),
HasBat: vals["p_has_bat"] == "1",
LastJumpDist: parseOr(vals["ac_last_dist"], 0.0),
SuspicionScore: int(parseOr(vals["ac_suspicion"], 0)),
RNG: NewRNG(rngState),
}
if v, ok := vals["obstacles"]; ok && v != "" {
json.Unmarshal([]byte(v), &s.Obstacles)
}
if v, ok := vals["platforms"]; ok && v != "" {
json.Unmarshal([]byte(v), &s.Platforms)
}
return s
}
func saveSimState(s *SimState) {
obsJson, _ := json.Marshal(s.Obstacles)
platJson, _ := json.Marshal(s.Platforms)
batStr := "0"
if s.HasBat {
batStr = "1"
}
rdb.HSet(ctx, "session:"+s.SessionID, map[string]interface{}{
"score": s.Score,
"total_ticks": s.Ticks,
"next_spawn_tick": s.NextSpawnTick,
"pos_y": fmt.Sprintf("%f", s.PosY),
"vel_y": fmt.Sprintf("%f", s.VelY),
"rng_state": s.RNG.State,
"obstacles": string(obsJson),
"platforms": string(platJson),
"p_god_lives": s.GodLives,
"p_has_bat": batStr,
"p_boot_ticks": s.BootTicks,
"ac_last_dist": fmt.Sprintf("%f", s.LastJumpDist),
"ac_suspicion": s.SuspicionScore,
})
}
func packResponse(s *SimState) (bool, int, []ActiveObstacle, []ActivePlatform, PowerUpState, int, int, uint32) {
pState := PowerUpState{
GodLives: s.GodLives,
HasBat: s.HasBat,
BootTicks: s.BootTicks,
}
return s.IsDead, s.Score, s.Obstacles, s.Platforms, pState, s.Ticks, s.NextSpawnTick, s.RNG.State
}
func checkPlatformLanding(pX, pY, pW, playerX, oldPlayerY, newPlayerY, velY float64) (bool, float64) {
if velY < 0 {
return false, 0
}
const pTolerance = 10.0
playerW := 30.0
if (playerX+playerW-pTolerance > pX) && (playerX+pTolerance < pX+pW) {
playerFeetOld := oldPlayerY + PlayerHeight
playerFeetNew := newPlayerY + PlayerHeight
if playerFeetOld <= pY && playerFeetNew >= pY {
return true, pY - PlayerHeight
}
}
return false, 0
}
func spawnRandomObstacle(s *SimState, speed, spawnX float64) {
gapPixel := 400 + int(s.RNG.NextRange(0, 500))
ticksToWait := int(float64(gapPixel) / speed)
s.NextSpawnTick = s.Ticks + ticksToWait
isBossPhase := (s.Ticks % 1500) > 1200
var possibleDefs []ObstacleDef
for _, d := range defaultConfig.Obstacles {
if isBossPhase {
if d.ID == "principal" || d.ID == "trashcan" {
possibleDefs = append(possibleDefs, d)
}
} else {
if d.ID == "principal" {
continue
}
if d.ID == "eraser" && s.Ticks < 3000 {
continue
}
possibleDefs = append(possibleDefs, d)
}
}
def := s.RNG.PickDef(possibleDefs)
if def != nil && def.CanTalk {
if s.RNG.NextFloat() > 0.7 {
s.RNG.NextFloat()
}
}
if def != nil && def.Type == "powerup" {
if s.RNG.NextFloat() > 0.1 {
def = nil
}
}
if def != nil {
spawnY := GroundY - def.Height - def.YOffset
s.Obstacles = append(s.Obstacles, ActiveObstacle{
ID: def.ID, Type: def.Type, X: spawnX, Y: spawnY, Width: def.Width, Height: def.Height,
})
}
}
func applyPowerup(s *SimState, id string) {
if id == "p_god" {
s.GodLives = 3
}
if id == "p_bat" {
s.HasBat = true
}
if id == "p_boot" {
s.BootTicks = 600
}
}
func checkGrounded(s *SimState) bool {
if s.VelY != 0 {
return false
}
if s.PosY >= PlayerYBase-0.1 {
return true
}
for _, p := range s.Platforms {
if math.Abs((s.PosY+PlayerHeight)-p.Y) < 0.5 {
if 50+30 > p.X && 50 < p.X+p.Width {
return true
}
}
}
return false
}
func isBotSpamming(inputs []Input) bool {
jumpCount := 0
for _, inp := range inputs {
if inp.Act == "JUMP" {
jumpCount++
}
}
if jumpCount > 10 {
log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge (%d)", sessionID, jumpCount)
return true, score, obstacles, PowerUpState{}, ticksAlive, nextSpawnTick
return jumpCount > 10
}
func parseInput(inputs []Input, currentTick int) (bool, bool) {
jump := false
duck := false
for _, inp := range inputs {
if inp.Tick == currentTick {
if inp.Act == "JUMP" {
jump = true
}
if inp.Act == "DUCK" {
duck = true
}
}
}
return jump, duck
}
playerDead := false
func calculateSpeed(ticks int) float64 {
speed := BaseSpeed + (float64(ticks)/1000.0)*1.5
if speed > 36.0 {
return 36.0
}
return speed
}
for i := 0; i < totalTicks; i++ {
ticksAlive++
currentSpeed := BaseSpeed + (float64(ticksAlive)/3000.0)*0.5
if currentSpeed > 12.0 {
currentSpeed = 12.0
}
currentJumpPower := JumpPower
if bootTicks > 0 {
currentJumpPower = HighJumpPower
bootTicks--
}
didJump := false
isCrouching := false
for _, inp := range inputs {
if inp.Tick == i {
if inp.Act == "JUMP" {
didJump = true
}
if inp.Act == "DUCK" {
isCrouching = true
}
}
}
isGrounded := posY >= PlayerYBase-1.0
currentHeight := PlayerHeight
if isCrouching {
currentHeight = PlayerHeight / 2
if !isGrounded {
velY += 2.0
}
}
if didJump && isGrounded && !isCrouching {
velY = currentJumpPower
var distToObs float64 = -1.0
for _, o := range obstacles {
if o.X > 50.0 {
distToObs = o.X - 50.0
break
}
}
if distToObs > 0 {
diff := math.Abs(distToObs - lastJumpDist)
if diff < 1.0 {
suspicionScore++
} else if suspicionScore > 0 {
suspicionScore--
}
lastJumpDist = distToObs
}
}
velY += Gravity
posY += velY
if posY > PlayerYBase {
posY = PlayerYBase
velY = 0
}
hitboxY := posY
if isCrouching {
hitboxY = posY + (PlayerHeight - currentHeight)
}
nextObstacles := []ActiveObstacle{}
for _, obs := range obstacles {
obs.X -= currentSpeed
if obs.X+obs.Width < -50.0 {
continue
}
paddingX := 10.0
realRightEdge := obs.X + obs.Width - paddingX
if realRightEdge < 55.0 {
nextObstacles = append(nextObstacles, obs)
continue
}
paddingY_Top := 10.0
if obs.Type == "teacher" {
paddingY_Top = 25.0
}
pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX
pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-5.0
oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX
oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-5.0
isCollision := pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom
if isCollision {
if obs.Type == "coin" {
score += 2000
continue
} else if obs.Type == "powerup" {
if obs.ID == "p_god" {
godLives = 3
}
if obs.ID == "p_bat" {
hasBat = true
}
if obs.ID == "p_boot" {
bootTicks = 600
}
continue
} else {
if hasBat && obs.Type == "teacher" {
hasBat = false
log.Printf("[%s] ⚾ Bat used on %s", sessionID, obs.ID)
continue
}
if godLives > 0 {
godLives--
log.Printf("[%s] 🛡️ Godmode saved life", sessionID)
continue
}
log.Printf("💀 DEATH [%s]: Hit %s at Tick %d", sessionID, obs.ID, ticksAlive)
playerDead = true
}
}
nextObstacles = append(nextObstacles, obs)
}
obstacles = nextObstacles
if nextSpawnTick == 0 {
nextSpawnTick = ticksAlive + 50
}
if ticksAlive >= nextSpawnTick {
gapPixel := 400 + int(rng.NextRange(0, 500))
ticksToWait := int(float64(gapPixel) / currentSpeed)
nextSpawnTick = ticksAlive + ticksToWait
spawnX := GameWidth + 50.0
isBossPhase := (ticksAlive % 1500) > 1200
var possibleDefs []ObstacleDef
for _, d := range defaultConfig.Obstacles {
if isBossPhase {
if d.ID == "principal" || d.ID == "trashcan" {
possibleDefs = append(possibleDefs, d)
}
} else {
if d.ID == "principal" {
continue
}
if d.ID == "eraser" && ticksAlive < 3000 {
continue
}
possibleDefs = append(possibleDefs, d)
}
}
def := rng.PickDef(possibleDefs)
if def != nil && def.CanTalk {
if rng.NextFloat() > 0.7 {
rng.NextFloat()
}
}
if def != nil {
if def.Type == "powerup" && rng.NextFloat() > 0.1 {
def = nil
}
if def != nil {
spawnY := GroundY - def.Height - def.YOffset
obstacles = append(obstacles, ActiveObstacle{
ID: def.ID,
Type: def.Type,
X: spawnX,
Y: spawnY,
Width: def.Width,
Height: def.Height,
})
}
}
}
if !playerDead {
score++
} else {
func checkJumpSuspicion(s *SimState) {
var distToObs float64 = -1.0
for _, o := range s.Obstacles {
if o.X > 50.0 {
distToObs = o.X - 50.0
break
}
}
if suspicionScore > 15 {
log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID)
playerDead = true
if distToObs > 0 {
diff := math.Abs(distToObs - s.LastJumpDist)
if diff < 1.0 {
s.SuspicionScore++
} else if s.SuspicionScore > 0 {
s.SuspicionScore--
}
s.LastJumpDist = distToObs
}
obsJson, _ := json.Marshal(obstacles)
batStr := "0"
if hasBat {
batStr = "1"
}
rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
"score": score,
"total_ticks": ticksAlive,
"next_spawn_tick": nextSpawnTick,
"pos_y": fmt.Sprintf("%f", posY),
"vel_y": fmt.Sprintf("%f", velY),
"rng_state": rng.State,
"obstacles": string(obsJson),
"p_god_lives": godLives,
"p_has_bat": batStr,
"p_boot_ticks": bootTicks,
"ac_last_dist": fmt.Sprintf("%f", lastJumpDist),
"ac_suspicion": suspicionScore,
})
pState := PowerUpState{
GodLives: godLives,
HasBat: hasBat,
BootTicks: bootTicks,
}
return playerDead, score, obstacles, pState, ticksAlive, nextSpawnTick
}
func parseOr(s string, def float64) float64 {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+4 -3
View File
@@ -8,13 +8,12 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<button id="mute-btn" onclick="toggleAudioClick()">🔊</button>
<div id="rotate-overlay">
<div class="icon">📱↻</div>
<p>Bitte Gerät drehen!</p>
<small>Querformat benötigt</small>
</div>
<div id="game-container">
<canvas id="gameCanvas"></canvas>
@@ -85,7 +84,7 @@
<h2 style="color:yellow">MEINE BEWEISE</h2>
<div id="codesList" style="font-size: 10px; line-height: 1.8;">
</div>
<p style="margin-top:20px; font-size:9px; color:#888;">Zeige diesen Code dem Lehrer für deinen Preis oder lösche den Eintrag.</p>
<p style="margin-top:20px; font-size:9px; color:#888;">Zeige diesen Code für deinen Preis oder lösche den Eintrag.</p>
</div>
</div>
@@ -135,6 +134,8 @@
<script src="js/config.js"></script>
<script src="js/state.js"></script>
<script src="js/audio.js"></script>
<script src="js/particles.js"></script>
<script src="js/network.js"></script>
<script src="js/input.js"></script>
<script src="js/logic.js"></script>
+57
View File
@@ -0,0 +1,57 @@
const SOUNDS = {
jump: new Audio('assets/sfx/jump.mp3'),
duck: new Audio('assets/sfx/duck.mp3'),
coin: new Audio('assets/sfx/coin.mp3'),
hit: new Audio('assets/sfx/hit.mp3'),
powerup: new Audio('assets/sfx/powerup.mp3'),
music: new Audio('assets/sfx/music_loop.mp3')
};
// Config
SOUNDS.jump.volume = 0.4;
SOUNDS.coin.volume = 0.3;
SOUNDS.hit.volume = 0.6;
SOUNDS.music.loop = true;
SOUNDS.music.volume = 0.2;
// --- STATUS LADEN ---
// Wir lesen den String 'true'/'false' aus dem LocalStorage
let isMuted = localStorage.getItem('escape_muted') === 'true';
function playSound(name) {
if (isMuted || !SOUNDS[name]) return;
const soundClone = SOUNDS[name].cloneNode();
soundClone.volume = SOUNDS[name].volume;
soundClone.play().catch(() => {});
}
function toggleMute() {
isMuted = !isMuted;
// --- STATUS SPEICHERN ---
localStorage.setItem('escape_muted', isMuted);
// Musik sofort pausieren/starten
if(isMuted) {
SOUNDS.music.pause();
} else {
// Nur starten, wenn wir schon im Spiel sind (user interaction needed)
// Wir fangen Fehler ab, falls der Browser Autoplay blockiert
SOUNDS.music.play().catch(()=>{});
}
return isMuted;
}
function startMusic() {
// Nur abspielen, wenn NICHT stummgeschaltet
if(!isMuted) {
SOUNDS.music.play().catch(e => console.log("Audio Autoplay blocked", e));
}
}
// Getter für UI
function getMuteState() {
return isMuted;
}
+22 -27
View File
@@ -1,34 +1,29 @@
// Konstanten
// ==========================================
// SPIEL KONFIGURATION & KONSTANTEN
// ==========================================
// Dimensionen (Muss zum Canvas passen)
const GAME_WIDTH = 800;
const GAME_HEIGHT = 400;
const GRAVITY = 0.6;
const JUMP_POWER = -12;
const HIGH_JUMP_POWER = -16;
const GROUND_Y = 350;
const BASE_SPEED = 5.0;
const CHUNK_SIZE = 60;
const TARGET_FPS = 60;
// Physik (Muss exakt synchron zum Go-Server sein!)
const GRAVITY = 1.8;
const JUMP_POWER = -20.0; // Vorher -36.0 (Deutlich weniger!)
const HIGH_JUMP_POWER = -28.0;// Vorher -48.0 (Boots)
const GROUND_Y = 350; // Y-Position des Bodens
// Geschwindigkeit
const BASE_SPEED = 15.0;
// Game Loop Einstellungen
const TARGET_FPS = 20;
const MS_PER_TICK = 1000 / TARGET_FPS;
const CHUNK_SIZE = 20; // Intervall für Berechnungen (Legacy)
// Debugging
// true = Zeigt Hitboxen (Grün) und Server-Daten (Cyan)
const DEBUG_SYNC = true;
const SYNC_TOLERANCE = 5.0;
// RNG Klasse
class PseudoRNG {
constructor(seed) {
this.state = BigInt(seed);
}
nextFloat() {
const a = 1664525n; const c = 1013904223n; const m = 4294967296n;
this.state = (this.state * a + c) % m;
return Number(this.state) / Number(m);
}
nextRange(min, max) {
return min + (this.nextFloat() * (max - min));
}
pick(array) {
if (!array || array.length === 0) return null;
const idx = Math.floor(this.nextRange(0, array.length));
return array[idx];
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
+81 -12
View File
@@ -1,45 +1,114 @@
// ==========================================
// INPUT HANDLING (WEBSOCKET VERSION)
// ==========================================
function handleInput(action, active) {
if (isGameOver) { if(active) location.reload(); return; }
const relativeTick = currentTick - lastSentTick;
// 1. Game Over Reset
if (isGameOver) {
if(active) location.reload();
return;
}
// 2. JUMP LOGIK
if (action === "JUMP" && active) {
// Wir prüfen lokal, ob wir springen dürfen (Client Prediction)
if (player.grounded && !isCrouching) {
// A. Sofort lokal anwenden (damit es sich direkt anfühlt)
player.vy = JUMP_POWER;
player.grounded = false;
inputLog.push({ t: relativeTick, act: "JUMP" });
playSound('jump');
spawnParticles(player.x + 15, player.y + 50, 'dust', 5); // Staubwolke an den Füßen
// B. An Server senden ("Ich habe JETZT gedrückt")
// Die Funktion sendInput ist in network.js definiert
if (typeof sendInput === "function") {
sendInput("input", "JUMP");
}
}
}
// 3. DUCK LOGIK
if (action === "DUCK") {
// Status merken, um unnötiges Senden zu vermeiden
const wasCrouching = isCrouching;
// A. Lokal anwenden
isCrouching = active;
// B. An Server senden (State Change: Start oder Ende)
if (wasCrouching !== isCrouching) {
if (typeof sendInput === "function") {
sendInput("input", active ? "DUCK_START" : "DUCK_END");
}
}
}
if (action === "DUCK") { isCrouching = active; }
}
// Event Listeners
// ==========================================
// EVENT LISTENERS
// ==========================================
// Tastatur
window.addEventListener('keydown', (e) => {
// Ignorieren, wenn User gerade Name in Highscore tippt
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true);
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true);
if (e.code === 'F9') {
e.preventDefault();
console.log("🐞 Fordere Debug-Daten vom Server an...");
if (typeof sendInput === "function") {
// Wir senden ein manuelles Paket, da sendInput meist nur für Game-Inputs ist
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "debug" }));
}
}
}
});
window.addEventListener('keyup', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
});
// Maus / Touch (Einfach)
window.addEventListener('mousedown', (e) => {
// Nur Linksklick und nur auf dem Canvas
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
});
// Touch Logic
// Touch (Swipe Gesten)
let touchStartY = 0;
window.addEventListener('touchstart', (e) => {
if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; }
if(e.target === canvas) {
e.preventDefault();
touchStartY = e.touches[0].clientY;
}
}, { passive: false });
window.addEventListener('touchend', (e) => {
if(e.target === canvas) {
e.preventDefault();
const diff = e.changedTouches[0].clientY - touchStartY;
if (diff < -30) handleInput("JUMP", true);
else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); }
else if (Math.abs(diff) < 10) handleInput("JUMP", true);
const touchEndY = e.changedTouches[0].clientY;
const diff = touchEndY - touchStartY;
// Nach oben wischen oder Tippen = Sprung
if (diff < -30) {
handleInput("JUMP", true);
}
// Nach unten wischen = Ducken (kurz)
else if (diff > 30) {
handleInput("DUCK", true);
setTimeout(() => handleInput("DUCK", false), 800);
}
// Einfaches Tippen (wenig Bewegung) = Sprung
else if (Math.abs(diff) < 10) {
handleInput("JUMP", true);
}
}
});
+173 -135
View File
@@ -1,172 +1,210 @@
function updateGameLogic() {
// 1. Input Logging (Ducken)
if (isCrouching) {
inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" });
}
// ===============================================
// 1. GESCHWINDIGKEIT
// ===============================================
// Wir nutzen den lokalen Score für die Geschwindigkeit
let currentSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
if (currentSpeed > 36.0) currentSpeed = 36.0;
// 2. Geschwindigkeit (Basiert auf ZEIT/Ticks, nicht Score!)
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
if (currentSpeed > 12.0) currentSpeed = 12.0;
updateParticles();
// 3. Spieler Physik & Größe
player.prevY = player.y;
obstacleBuffer.forEach(o => o.prevX = o.x);
platformBuffer.forEach(p => p.prevX = p.x);
// ===============================================
// 2. SPIELER PHYSIK (CLIENT PREDICTION)
// ===============================================
const originalHeight = 50;
const crouchHeight = 25;
// Hitbox & Y-Pos anpassen
player.h = isCrouching ? crouchHeight : originalHeight;
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
// Alte Position (für One-Way Check)
const oldY = player.y;
// Physik
player.vy += GRAVITY;
if (isCrouching && !player.grounded) player.vy += 2.0; // Fast Fall
player.y += player.vy;
if (isCrouching && !player.grounded) player.vy += 2.0;
if (player.y + originalHeight >= GROUND_Y) {
player.y = GROUND_Y - originalHeight;
let newY = player.y + player.vy;
let landed = false;
// --- PLATTFORMEN ---
if (player.vy > 0) {
for (let plat of platformBuffer) {
// Nur relevante Plattformen prüfen
if (plat.x < GAME_WIDTH + 100 && plat.x > -100) {
if (player.x + 30 > plat.x && player.x < plat.x + plat.w) {
// "Passed Check": Vorher drüber, jetzt drauf/drunter
const feetOld = oldY + originalHeight;
const feetNew = newY + originalHeight;
if (feetOld <= plat.y && feetNew >= plat.y) {
newY = plat.y - originalHeight;
player.vy = 0;
landed = true;
sendPhysicsSync(newY, 0);
break;
}
}
}
}
}
// --- BODEN ---
if (!landed && newY + originalHeight >= GROUND_Y) {
newY = GROUND_Y - originalHeight;
player.vy = 0;
player.grounded = true;
} else {
player.grounded = false;
landed = true;
}
// 4. Hindernisse Bewegen & Kollision
let nextObstacles = [];
if (currentTick % 10 === 0) {
sendPhysicsSync(player.y, player.vy);
}
for (let obs of obstacles) {
obs.x -= currentSpeed;
player.y = newY;
player.grounded = landed;
// Aufräumen, wenn links raus
if (obs.x + obs.def.width < -50.0) continue;
// ===============================================
// 3. PUFFER BEWEGEN (STREAMING)
// ===============================================
// --- PASSED CHECK (Wichtig!) ---
// Wenn das Hindernis den Spieler schon passiert hat, ignorieren wir Kollisionen.
// Das verhindert "Geister-Treffer" von hinten durch CCD.
const paddingX = 10;
const realRightEdge = obs.x + obs.def.width - paddingX;
obstacleBuffer.forEach(o => o.x -= currentSpeed);
platformBuffer.forEach(p => p.x -= currentSpeed);
// Spieler ist bei 50. Wir geben 5px Puffer.
if (realRightEdge < 55) {
nextObstacles.push(obs); // Behalten, aber keine Kollisionsprüfung mehr
continue;
}
// -------------------------------
// Aufräumen (Links raus)
obstacleBuffer = obstacleBuffer.filter(o => o.x + (o.w||30) > -200); // Muss -200 sein
platformBuffer = platformBuffer.filter(p => p.x + (p.w||100) > -200); // Muss -200 sein
// Kollisionsprüfung
const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
// ===============================================
// 4. KOLLISION & TRANSFER (LOGIK + RENDER LISTE)
// ===============================================
if (checkCollision(playerHitbox, obs)) {
// A. COIN
if (obs.def.type === "coin") {
score += 2000;
continue; // Entfernen
}
// B. POWERUP
else if (obs.def.type === "powerup") {
if (obs.def.id === "p_god") godModeLives = 3;
if (obs.def.id === "p_bat") hasBat = true;
if (obs.def.id === "p_boot") bootTicks = 600;
lastPowerupTick = currentTick; // Für Sync merken
continue; // Entfernen
}
// C. GEGNER
else {
if (hasBat && obs.def.type === "teacher") {
hasBat = false;
continue; // Zerstört
}
if (godModeLives > 0) {
godModeLives--;
continue; // Geschützt
obstacles = [];
platforms = [];
const RENDER_LIMIT = 900;
// Hitbox definieren (für lokale Prüfung)
const pHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
// --- HINDERNISSE ---
obstacleBuffer.forEach(obs => {
// Nur verarbeiten, wenn im Sichtbereich
if (obs.x < RENDER_LIMIT) {
// A. Metadaten laden (falls noch nicht da)
if (!obs.def) {
let baseDef = null;
if(gameConfig && gameConfig.obstacles) {
baseDef = gameConfig.obstacles.find(x => x.id === obs.id);
}
obs.def = {
id: obs.id,
type: obs.type || (baseDef ? baseDef.type : "obstacle"),
width: obs.w || (baseDef ? baseDef.width : 30),
height: obs.h || (baseDef ? baseDef.height : 30),
color: obs.color || (baseDef ? baseDef.color : "red"),
image: baseDef ? baseDef.image : null,
imgScale: baseDef ? baseDef.imgScale : 1.0,
imgOffsetX: baseDef ? baseDef.imgOffsetX : 0,
imgOffsetY: baseDef ? baseDef.imgOffsetY : 0
};
}
player.color = "darkred";
if (!isGameOver) {
sendChunk();
gameOver("Kollision");
// B. Kollision prüfen (Nur wenn noch nicht eingesammelt)
// Wir nutzen 'obs.collected' als Flag, damit wir Coins nicht doppelt zählen
if (!obs.collected && !isGameOver) {
if (checkCollision(pHitbox, obs)) {
const type = obs.def.type;
const id = obs.def.id;
// 1. COIN
if (type === "coin") {
score += 2000; // Sofort addieren!
obs.collected = true; // Markieren als "weg"
playSound('coin');
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 10);
}
// 2. POWERUP
else if (type === "powerup") {
if (id === "p_god") godModeLives = 3;
if (id === "p_bat") hasBat = true;
if (id === "p_boot") bootTicks = 600; // ca. 10 Sekunden
playSound('powerup');
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 20); // Mehr Partikel
obs.collected = true; // Markieren als "weg"
}
// 3. GEGNER (Teacher/Obstacle)
else {
// Baseballschläger vs Lehrer
if (hasBat && type === "teacher") {
hasBat = false;
obs.collected = true; // Wegschlagen
playSound('hit');
spawnParticles(obs.x, obs.y, 'explosion', 5);
// Effekt?
}
// Godmode (Schild)
else if (godModeLives > 0) {
godModeLives--;
// Optional: Gegner entfernen oder durchlaufen lassen?
// Hier entfernen wir ihn, damit man nicht 2 Leben im selben Objekt verliert
obs.collected = true;
}
// TOT
else {
console.log("💥 Kollision!");
player.color = "darkred";
gameOver("Kollision");
playSound('hit');
spawnParticles(player.x + 15, player.y + 25, 'explosion', 50); // Riesige Explosion
if (typeof sendInput === "function") sendInput("input", "DEATH");
}
}
}
}
}
nextObstacles.push(obs);
}
obstacles = nextObstacles;
// 5. Spawning (Zeitbasiert & Synchron)
// Fallback für Init
if (typeof nextSpawnTick === 'undefined' || nextSpawnTick === 0) {
nextSpawnTick = currentTick + 50;
}
if (currentTick >= nextSpawnTick && gameConfig) {
// A. Nächsten Termin berechnen
const gapPixel = Math.floor(400 + rng.nextRange(0, 500));
const ticksToWait = Math.floor(gapPixel / currentSpeed);
nextSpawnTick = currentTick + ticksToWait;
// B. Position setzen (Fix rechts außen)
let spawnX = GAME_WIDTH + 50;
// C. Objekt auswählen
const isBossPhase = (currentTick % 1500) > 1200;
let possibleObs = [];
gameConfig.obstacles.forEach(def => {
if (isBossPhase) {
if (def.id === "principal" || def.id === "trashcan") possibleObs.push(def);
} else {
if (def.id === "principal") return;
// Eraser erst ab Tick 3000
if (def.id === "eraser" && currentTick < 3000) return;
possibleObs.push(def);
// C. Zur Render-Liste hinzufügen (Nur wenn NICHT eingesammelt)
if (!obs.collected) {
obstacles.push(obs);
}
});
let def = rng.pick(possibleObs);
// RNG Sync: Speech
let speech = null;
if (def && def.canTalk) {
if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines);
}
});
// RNG Sync: Powerup Rarity
if (def && def.type === "powerup") {
if (rng.nextFloat() > 0.1) def = null;
// --- PLATTFORMEN ---
platformBuffer.forEach(plat => {
if (plat.x < RENDER_LIMIT) {
platforms.push(plat);
}
if (def) {
const yOffset = def.yOffset || 0;
obstacles.push({
x: spawnX,
y: GROUND_Y - def.height - yOffset,
def: def,
speech: speech
});
}
}
});
}
// Helper: Robuste Kollisionsprüfung
function checkCollision(p, obs) {
const paddingX = 10;
const paddingY_Top = (obs.def.type === "teacher") ? 25 : 10;
const paddingY_Bottom = 5;
const def = obs.def || {};
const w = def.width || obs.w || 30;
const h = def.height || obs.h || 30;
// Speed-basierte Hitbox-Erweiterung (CCD)
// Wir schätzen den Speed hier, damit er ungefähr dem Server entspricht
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
if (currentSpeed > 12.0) currentSpeed = 12.0;
// Kleines Padding, damit es fair ist
const padX = 8;
const padY = (def.type === "teacher" || def.type === "principal") ? 20 : 5;
const pLeft = p.x + paddingX;
const pRight = p.x + p.w - paddingX;
const pTop = p.y + paddingY_Top;
const pBottom = p.y + p.h - paddingY_Bottom;
// Koordinaten
const pL = p.x + padX;
const pR = p.x + p.w - padX;
const pT = p.y + padY;
const pB = p.y + p.h - 5;
const oLeft = obs.x + paddingX;
// Wir erweitern die Hitbox nach rechts um die Geschwindigkeit,
// um schnelle Durchschüsse zu verhindern.
const oRight = obs.x + obs.def.width - paddingX + currentSpeed;
const oL = obs.x + padX;
const oR = obs.x + w - padX;
const oT = obs.y + padY;
const oB = obs.y + h - 5;
const oTop = obs.y + paddingY_Top;
const oBottom = obs.y + obs.def.height - paddingY_Bottom;
return (pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom);
return (pR > oL && pL < oR && pB > oT && pT < oB);
}
+240 -178
View File
@@ -1,247 +1,163 @@
// ==========================================
// INIT & ASSETS
// 1. ASSETS LADEN
// ==========================================
async function loadAssets() {
playerSprite.src = "assets/player.png";
const pPromise = new Promise(resolve => {
playerSprite.src = "assets/player.png";
playerSprite.onload = resolve;
playerSprite.onerror = () => { resolve(); };
});
// Hintergründe laden
const bgPromises = gameConfig.backgrounds.map((bgFile, index) => {
return new Promise((resolve) => {
const img = new Image();
img.src = "assets/" + bgFile;
img.onload = () => { bgSprites[index] = img; resolve(); };
img.onerror = () => {
console.warn("BG fehlt:", bgFile);
bgSprites[index] = null;
resolve();
};
});
});
// Hindernisse laden
const obsPromises = gameConfig.obstacles.map(def => {
return new Promise((resolve) => {
if (!def.image) { resolve(); return; }
const img = new Image(); img.src = "assets/" + def.image;
img.onload = () => { sprites[def.id] = img; resolve(); };
img.onerror = () => { resolve(); };
});
});
// Player laden (kleiner Promise Wrapper)
const pPromise = new Promise(r => {
playerSprite.onload = r;
playerSprite.onerror = r;
const obsPromises = gameConfig.obstacles.map(def => {
return new Promise((resolve) => {
if (!def.image) { resolve(); return; }
const img = new Image();
img.src = "assets/" + def.image;
img.onload = () => { sprites[def.id] = img; resolve(); };
img.onerror = () => { resolve(); };
});
});
await Promise.all([pPromise, ...bgPromises, ...obsPromises]);
}
// ==========================================
// START LOGIK
// 2. SPIEL STARTEN
// ==========================================
window.startGameClick = async function() {
if (!isLoaded) return;
startScreen.style.display = 'none';
document.body.classList.add('game-active');
try {
const sRes = await fetch('/api/start', {method:'POST'});
const sData = await sRes.json();
sessionID = sData.sessionId;
rng = new PseudoRNG(sData.seed);
isGameRunning = true;
maxRawBgIndex = 0;
lastTime = performance.now();
resize();
} catch(e) {
alert("Start Fehler: " + e.message);
location.reload();
}
// Score Reset visuell
score = 0;
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = "0";
// WebSocket Start
startMusic();
connectGame();
resize();
};
// ==========================================
// SCORE EINTRAGEN
// 3. GAME OVER & HIGHSCORE LOGIK
// ==========================================
window.gameOver = function(reason) {
if (isGameOver) return;
isGameOver = true;
console.log("Game Over:", reason);
const finalScore = Math.floor(score / 10);
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
if (finalScore > currentHighscore) {
localStorage.setItem('escape_highscore', finalScore);
}
if (gameOverScreen) {
gameOverScreen.style.display = 'flex';
document.getElementById('finalScore').innerText = finalScore;
// Input wieder anzeigen
document.getElementById('inputSection').style.display = 'flex';
document.getElementById('submitBtn').disabled = false;
// Liste laden
loadLeaderboard();
}
};
// Name absenden (Button Click)
window.submitScore = async function() {
const nameInput = document.getElementById('playerNameInput');
const name = nameInput.value;
const name = nameInput.value.trim();
const btn = document.getElementById('submitBtn');
if (!name) return alert("Namen eingeben!");
if (!name) return alert("Bitte Namen eingeben!");
btn.disabled = true;
try {
const res = await fetch('/api/submit-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sessionId: sessionID, name: name })
body: JSON.stringify({ sessionId: sessionID, name: name }) // sessionID aus state.js
});
if (!res.ok) throw new Error("Server Error");
if (!res.ok) throw new Error("Fehler beim Senden");
const data = await res.json();
// Code lokal speichern (Claims)
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
myClaims.push({
name: name, score: Math.floor(score / 10), code: data.claimCode,
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
name: name,
score: Math.floor(score / 10),
code: data.claimCode,
date: new Date().toLocaleString('de-DE'),
sessionId: sessionID
});
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
// UI Update
document.getElementById('inputSection').style.display = 'none';
loadLeaderboard();
alert(`Gespeichert! Code: ${data.claimCode}`);
alert(`Gespeichert! Dein Code: ${data.claimCode}`);
} catch (e) {
alert("Fehler: " + e.message);
console.error(e);
alert("Fehler beim Speichern: " + e.message);
btn.disabled = false;
}
};
// ==========================================
// MEINE CODES & LÖSCHEN
// ==========================================
window.showMyCodes = function() {
if(window.openModal) window.openModal('codes');
const listEl = document.getElementById('codesList');
if(!listEl) return;
const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
if (rawClaims.length === 0) {
listEl.innerHTML = "<div style='padding:10px; text-align:center; color:#666;'>Keine Codes gespeichert.</div>";
return;
}
const sortedClaims = rawClaims
.map((item, index) => ({ ...item, originalIndex: index }))
.sort((a, b) => b.score - a.score);
let html = "";
sortedClaims.forEach(c => {
const canDelete = c.sessionId ? true : false;
const btnStyle = canDelete ? "cursor:pointer; color:#ff4444; border-color:#ff4444;" : "cursor:not-allowed; color:gray; border-color:gray;";
const btnAttr = canDelete ? `onclick="deleteClaim(${c.originalIndex}, '${c.sessionId}', '${c.code}')"` : "disabled";
let rankIcon = "📄";
if (c.score >= 10000) rankIcon = "🔥";
if (c.score >= 5000) rankIcon = "⭐";
html += `
<div style="border-bottom:1px solid #444; padding:8px 0; display:flex; justify-content:space-between; align-items:center;">
<div style="text-align:left;">
<span style="color:#00e5ff; font-weight:bold; font-size:12px;">${rankIcon} ${c.code}</span>
<span style="color:#ffcc00; font-weight:bold;">(${c.score} Pkt)</span><br>
<span style="color:#aaa; font-size:9px;">${c.name}${c.date}</span>
</div>
<button ${btnAttr}
style="background:transparent; border:1px solid; padding:5px; font-size:9px; margin:0; ${btnStyle}">
LÖSCHEN
</button>
</div>`;
});
listEl.innerHTML = html;
};
window.deleteClaim = async function(index, sid, code) {
if(!confirm("Wirklich löschen?")) return;
try {
const res = await fetch('/api/claim/delete', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ sessionId: sid, claimCode: code })
});
if (!res.ok) {
if(!confirm("Server Fehler (evtl. schon weg). Lokal löschen?")) return;
}
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
claims.splice(index, 1);
localStorage.setItem('escape_claims', JSON.stringify(claims));
window.showMyCodes();
loadLeaderboard();
} catch(e) { alert("Verbindungsfehler!"); }
};
// Bestenliste laden (Game Over Screen)
async function loadLeaderboard() {
try {
// sessionID wird mitgesendet, um den eigenen Eintrag zu markieren
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
const entries = await res.json();
let html = "<h3 style='margin-bottom:5px'>BESTENLISTE</h3>";
let html = "<h3 style='margin-bottom:5px; color:#ffcc00;'>BESTENLISTE</h3>";
if(entries.length === 0) html += "<div>Noch keine Einträge.</div>";
entries.forEach(e => {
const color = e.isMe ? "yellow" : "white";
const bgStyle = e.isMe ? "background:rgba(255,255,0,0.1);" : "";
const betterThanMe = e.rank - 1;
let infoText = "";
if (e.isMe && betterThanMe > 0) {
infoText = `<div style='font-size:8px; color:#aaa;'>(${betterThanMe} waren besser)</div>`;
} else if (e.isMe && betterThanMe === 0) {
infoText = `<div style='font-size:8px; color:#ffcc00;'>👑 NIEMAND ist besser!</div>`;
}
const color = e.isMe ? "cyan" : "white"; // Eigener Name in Cyan
const bgStyle = e.isMe ? "background:rgba(0,255,255,0.1);" : "";
html += `
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} margin-bottom:2px;">
<div style="display:flex; justify-content:space-between; color:${color};">
<span>#${e.rank} ${e.name.toUpperCase()}</span>
<span>${Math.floor(e.score/10)}</span>
</div>
${infoText}
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} display:flex; justify-content:space-between; color:${color}; font-size:12px;">
<span>#${e.rank} ${e.name}</span>
<span>${Math.floor(e.score/10)}</span>
</div>`;
if(e.rank === 3 && entries.length > 3 && !entries[3].isMe) {
html += "<div style='text-align:center; color:gray; font-size:8px;'>...</div>";
}
});
document.getElementById('leaderboard').innerHTML = html;
} catch(e) { console.error(e); }
}
async function loadStartScreenLeaderboard() {
try {
const listEl = document.getElementById('startLeaderboardList');
if (!listEl) return;
const res = await fetch('/api/leaderboard');
const entries = await res.json();
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Noch keine Scores.</div>"; return; }
let html = "";
entries.forEach(e => {
let icon = "#" + e.rank;
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
});
listEl.innerHTML = html;
} catch (e) {}
}
function gameOver(reason) {
if (isGameOver) return;
isGameOver = true;
const finalScoreVal = Math.floor(score / 10);
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
if (finalScoreVal > currentHighscore) localStorage.setItem('escape_highscore', finalScoreVal);
gameOverScreen.style.display = 'flex';
document.getElementById('finalScore').innerText = finalScoreVal;
loadLeaderboard();
drawGame();
} catch(e) {
console.error("Leaderboard Error:", e);
}
}
// ==========================================
// DER FIXIERTE GAME LOOP
// 4. GAME LOOP
// ==========================================
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
// 1. Wenn Assets noch nicht da sind, machen wir gar nichts
if (!isLoaded) return;
// 2. PHYSIK-LOGIK (Nur wenn Spiel läuft und nicht Game Over)
// Das hier sorgt dafür, dass der Dino stehen bleibt, wenn wir im Menü sind
if (isGameRunning && !isGameOver) {
if (!lastTime) lastTime = timestamp;
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
@@ -254,28 +170,34 @@ function gameLoop(timestamp) {
updateGameLogic();
currentTick++;
score++;
if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
accumulator -= MS_PER_TICK;
}
const alpha = accumulator / MS_PER_TICK;
// Score im HUD
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
}
// 3. RENDERING (IMMER!)
// Das hier war das Problem. Früher stand hier "return" wenn !isGameRunning.
// Jetzt malen wir immer. Wenn isGameRunning false ist, malt er einfach den Start-Zustand.
drawGame();
drawGame(isGameRunning ? accumulator / MS_PER_TICK : 1.0);
}
// ==========================================
// 5. INIT
// ==========================================
async function initGame() {
try {
const cRes = await fetch('/api/config'); gameConfig = await cRes.json();
const cRes = await fetch('/api/config');
gameConfig = await cRes.json();
// Erst alles laden
await loadAssets();
await loadStartScreenLeaderboard();
if (typeof getMuteState === 'function') {
updateMuteIcon(getMuteState());
}
isLoaded = true;
if(loadingText) loadingText.style.display = 'none';
if(startBtn) startBtn.style.display = 'inline-block';
@@ -284,10 +206,7 @@ async function initGame() {
const hsEl = document.getElementById('localHighscore');
if(hsEl) hsEl.innerText = savedHighscore;
// Loop starten (mit dummy timestamp start)
requestAnimationFrame(gameLoop);
// Initiales Zeichnen erzwingen (damit Hintergrund sofort da ist)
drawGame();
} catch(e) {
@@ -296,4 +215,147 @@ async function initGame() {
}
}
// Helper: Mini-Leaderboard auf Startseite
async function loadStartScreenLeaderboard() {
try {
const listEl = document.getElementById('startLeaderboardList');
if (!listEl) return;
const res = await fetch('/api/leaderboard');
const entries = await res.json();
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Keine Scores.</div>"; return; }
let html = "";
entries.forEach(e => {
let icon = "#" + e.rank;
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
});
listEl.innerHTML = html;
} catch (e) {}
}
// Audio Toggle Funktion für den Button
window.toggleAudioClick = function() {
// 1. Audio umschalten (in audio.js)
const muted = toggleMute();
// 2. Button Icon updaten
updateMuteIcon(muted);
// 3. Fokus vom Button nehmen (damit Space nicht den Button drückt, sondern springt)
document.getElementById('mute-btn').blur();
};
function updateMuteIcon(isMuted) {
const btn = document.getElementById('mute-btn');
if (btn) {
btn.innerText = isMuted ? "🔇" : "🔊";
btn.style.color = isMuted ? "#ff4444" : "white";
btn.style.borderColor = isMuted ? "#ff4444" : "#555";
}
}
// ==========================================
// MEINE CODES (LOCAL STORAGE)
// ==========================================
// 1. Codes anzeigen (Wird vom Button im Startscreen aufgerufen)
window.showMyCodes = function() {
// Modal öffnen
openModal('codes');
const listEl = document.getElementById('codesList');
if(!listEl) return;
// Daten aus dem Browser-Speicher holen
const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
if (rawClaims.length === 0) {
listEl.innerHTML = "<div style='padding:20px; text-align:center; color:#666;'>Keine Codes gespeichert.</div>";
return;
}
// Sortieren nach Score (Höchster zuerst)
const sortedClaims = rawClaims
.map((item, index) => ({ ...item, originalIndex: index }))
.sort((a, b) => b.score - a.score);
let html = "";
sortedClaims.forEach(c => {
// Icons basierend auf Score
let rankIcon = "📄";
if (c.score >= 5000) rankIcon = "⭐";
if (c.score >= 10000) rankIcon = "🔥";
if (c.score >= 20000) rankIcon = "👑";
html += `
<div style="border-bottom:1px solid #444; padding:10px 0; display:flex; justify-content:space-between; align-items:center;">
<div style="text-align:left;">
<span style="color:#00e5ff; font-weight:bold; font-size:14px;">${rankIcon} ${c.code}</span>
<span style="color:#ffcc00; font-weight:bold;">(${c.score} Pkt)</span><br>
<span style="color:#aaa; font-size:10px;">${c.name}${c.date}</span>
</div>
<button onclick="deleteClaim('${c.sessionId}', '${c.code}')"
style="background:transparent; border:1px solid #ff4444; color:#ff4444; padding:5px 10px; font-size:10px; cursor:pointer;">
LÖSCHEN
</button>
</div>`;
});
listEl.innerHTML = html;
};
// 2. Code löschen (Lokal und auf Server)
window.deleteClaim = async function(sid, code) {
if(!confirm("Eintrag wirklich löschen?")) return;
// Versuch, es auf dem Server zu löschen
try {
await fetch('/api/claim/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sessionId: sid, claimCode: code })
});
} catch(e) {
console.warn("Server Delete fehlgeschlagen (vielleicht schon weg), lösche lokal...");
}
// Lokal löschen
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
// Wir filtern den Eintrag raus, der die gleiche SessionID UND den gleichen Code hat
claims = claims.filter(c => c.code !== code);
localStorage.setItem('escape_claims', JSON.stringify(claims));
// Liste aktualisieren
window.showMyCodes();
// Leaderboard aktualisieren (falls im Hintergrund sichtbar)
if(document.getElementById('startLeaderboardList')) {
loadStartScreenLeaderboard();
}
};
// ==========================================
// MODAL LOGIK (Fenster auf/zu)
// ==========================================
window.openModal = function(id) {
const el = document.getElementById('modal-' + id);
if(el) el.style.display = 'flex';
}
window.closeModal = function() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(el => el.style.display = 'none');
}
// Klick nebendran schließt Modal
window.onclick = function(event) {
if (event.target.classList.contains('modal-overlay')) {
closeModal();
}
}
initGame();
+337 -159
View File
@@ -1,199 +1,377 @@
async function sendChunk() {
const ticksToSend = currentTick - lastSentTick;
if (ticksToSend <= 0) return;
// ==========================================
// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
// ==========================================
/*
GLOBALE VARIABLEN (aus state.js):
- socket
- obstacleBuffer, platformBuffer
- currentLatencyMs, pingInterval
- isGameRunning, isGameOver
- score, currentTick
*/
const snapshotobstacles = JSON.parse(JSON.stringify(obstacles));
function connectGame() {
// Alte Verbindung schließen
if (socket) {
socket.close();
}
const payload = {
sessionId: sessionID,
inputs: [...inputLog],
totalTicks: ticksToSend
// Ping Timer stoppen falls aktiv
if (typeof pingInterval !== 'undefined' && pingInterval) {
clearInterval(pingInterval);
}
// Protokoll automatisch wählen (ws:// oder wss://)
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + "//" + location.host + "/ws";
console.log("Verbinde zu:", url);
socket = new WebSocket(url);
// --- 1. VERBINDUNG GEÖFFNET ---
socket.onopen = () => {
console.log("🟢 WS Verbunden. Spiel startet.");
// Alles zurücksetzen
obstacleBuffer = [];
platformBuffer = [];
obstacles = [];
platforms = [];
currentLatencyMs = 0; // Reset Latenz
isGameRunning = true;
isGameOver = false;
isLoaded = true;
// PING LOOP STARTEN (Jede Sekunde messen)
pingInterval = setInterval(sendPing, 1000);
// Game Loop anwerfen
requestAnimationFrame(gameLoop);
};
inputLog = [];
lastSentTick = currentTick;
// --- 2. NACHRICHT VOM SERVER ---
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
try {
const res = await fetch('/api/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
// A. PONG (Latenzmessung)
if (msg.type === "pong") {
const now = Date.now();
const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
const data = await res.json();
// Round Trip Time (Hin + Zurück)
const rtt = now - sentTime;
// Update für visuelles Debugging
if (data.serverObs) {
serverObstacles = data.serverObs;
// One Way Latency (Latenz in eine Richtung)
const latency = rtt / 2;
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
compareState(snapshotobstacles, data.serverObs);
}
if (data.powerups) {
const sTick = data.serverTick;
if (lastPowerupTick > sTick) {
// Glätten (Exponential Moving Average), damit Werte nicht springen
// Wenn es der erste Wert ist, nehmen wir ihn direkt.
if (currentLatencyMs === 0) {
currentLatencyMs = latency;
} else {
godModeLives = data.powerups.godLives;
hasBat = data.powerups.hasBat;
bootTicks = data.powerups.bootTicks;
// 90% alter Wert, 10% neuer Wert
currentLatencyMs = (currentLatencyMs * 0.9) + (latency * 0.1);
}
// Optional: Debugging im Log
// console.log(`📡 Ping: ${rtt}ms | Latenz: ${currentLatencyMs.toFixed(1)}ms`);
}
// B. CHUNK (Objekte empfangen)
if (msg.type === "chunk") {
// 1. CLOCK SYNC (Die Zeitmaschine)
// Wenn der Server bei Tick 204 ist und wir bei 182, müssen wir aufholen!
// Wir addieren die geschätzte Latenz (in Ticks) auf die Serverzeit.
// 60 FPS = 16ms/Tick. 20 TPS = 50ms/Tick.
const msPerTick = 1000 / 20; // WICHTIG: Wir laufen auf 20 TPS Basis!
const latencyInTicks = Math.floor(currentLatencyMs / msPerTick);
// Ziel-Zeit: Server-Zeit + Übertragungsweg
const targetTick = msg.serverTick + latencyInTicks;
const drift = targetTick - currentTick;
// Wenn wir mehr als 2 Ticks abweichen -> Korrigieren
if (Math.abs(drift) > 2) {
// console.log(`⏰ Clock Sync: ${currentTick} -> ${targetTick} (Drift: ${drift})`);
currentTick = targetTick; // Harter Sync, damit Physik stimmt
}
// 2. PIXEL KORREKTUR (Sanfter!)
// Wir berechnen den Speed
let sTick = msg.serverTick;
// Formel aus logic.js (Base 15 + Zeit)
let currentSpeedPerTick = 15.0 + (sTick / 1000.0) * 1.5;
if (currentSpeedPerTick > 36) currentSpeedPerTick = 36;
const speedPerMs = currentSpeedPerTick / msPerTick; // Speed pro MS
// Korrektur: Latenz * Speed
// FIX: Wir kappen die Korrektur bei max 100px, damit Objekte nicht "teleportieren".
let dynamicCorrection = (currentLatencyMs * speedPerMs) + 5;
if (dynamicCorrection > 100) dynamicCorrection = 100; // Limit
// Puffer füllen (mit Limit)
if (msg.obstacles) {
msg.obstacles.forEach(o => {
o.x -= dynamicCorrection;
// Init für Interpolation
o.prevX = o.x;
obstacleBuffer.push(o);
});
}
if (msg.platforms) {
msg.platforms.forEach(p => {
p.x -= dynamicCorrection;
p.prevX = p.x;
platformBuffer.push(p);
});
}
if (msg.score !== undefined) score = msg.score;
// Powerups übernehmen (für Anzeige)
if (msg.powerups) {
godModeLives = msg.powerups.godLives;
hasBat = msg.powerups.hasBat;
bootTicks = msg.powerups.bootTicks;
}
}
// Sync Spawning Timer
if (data.NextSpawnTick) {
if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
nextSpawnTick = data.nextSpawnTick;
if (msg.type === "init") {
console.log("📩 INIT EMPFANGEN:", msg); // <--- DEBUG LOG
if (msg.sessionId) {
sessionID = msg.sessionId; // Globale Variable setzen
console.log("🔑 Session ID gesetzt auf:", sessionID);
} else {
console.error("❌ INIT FEHLER: Keine sessionId im Paket!", msg);
}
}
}
// C. TOD (Server Authoritative)
if (msg.type === "dead") {
console.log("💀 Server sagt: Game Over");
if (data.status === "dead") {
console.error("💀 SERVER KILL", data);
gameOver("Vom Server gestoppt");
} else {
const sScore = data.verifiedScore;
// Score Korrektur
if (Math.abs(score - sScore) > 200) {
console.warn(`⚠️ SCORE DRIFT: Client=${score} Server=${sScore}`);
score = sScore;
if (msg.score) score = msg.score;
// Verbindung sauber trennen
socket.close();
if (pingInterval) clearInterval(pingInterval);
gameOver("Vom Server gestoppt");
}
if (msg.type === "debug_sync") {
// 1. CLIENT SPEED BERECHNEN (Formel aus logic.js)
// Wir nutzen hier 'score', da logic.js das auch tut
let clientSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
if (clientSpeed > 36.0) clientSpeed = 36.0;
// 2. SERVER SPEED HOLEN
let serverSpeed = msg.currentSpeed || 0;
// 3. DIFF BERECHNEN
let diffSpeed = clientSpeed - serverSpeed;
let speedIcon = Math.abs(diffSpeed) < 0.01 ? "✅" : "❌";
console.group(`📊 SYNC REPORT (Tick: ${currentTick} vs Server: ${msg.serverTick})`);
// --- DER NEUE SPEED CHECK ---
console.log(`🚀 SPEED CHECK: ${speedIcon}`);
console.log(` Client: ${clientSpeed.toFixed(4)} px/tick (Basis: Tick ${currentTick})`);
console.log(` Server: ${serverSpeed.toFixed(4)} px/tick (Basis: Tick ${msg.serverTick})`);
if (Math.abs(diffSpeed) > 0.01) {
console.warn(`⚠️ ACHTUNG: Geschwindigkeiten weichen ab! Diff: ${diffSpeed.toFixed(4)}`);
console.warn("Ursache: Client nutzt 'Score', Server nutzt 'Ticks'. Sind diese synchron?");
}
// -----------------------------
// 1. Hindernisse vergleichen
generateSyncTable("Obstacles", obstacles, msg.obstacles);
// 2. Plattformen vergleichen
generateSyncTable("Platforms", platforms, msg.platforms);
console.groupEnd();
}
} catch (e) {
console.error("Fehler beim Verarbeiten der Nachricht:", e);
}
};
} catch (e) {
console.error("Netzwerkfehler:", e);
// --- 3. VERBINDUNG GETRENNT ---
socket.onclose = () => {
console.log("🔴 WS Verbindung getrennt.");
if (pingInterval) clearInterval(pingInterval);
};
socket.onerror = (error) => {
console.error("WS Fehler:", error);
};
}
// ==========================================
// PING SENDEN
// ==========================================
function sendPing() {
if (socket && socket.readyState === WebSocket.OPEN) {
// Wir senden den aktuellen Zeitstempel
// Der Server muss diesen im "tick" Feld zurückschicken (siehe websocket.go)
socket.send(JSON.stringify({
type: "ping",
tick: Date.now() // Timestamp als Integer
}));
}
}
window.submitScore = async function() {
const nameInput = document.getElementById('playerNameInput');
const name = nameInput.value;
const btn = document.getElementById('submitBtn');
if (!name) return alert("Namen eingeben!");
btn.disabled = true;
try {
const res = await fetch('/api/submit-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sessionId: sessionID, name: name })
});
const data = await res.json();
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
myClaims.push({
name: name, score: Math.floor(score / 10), code: data.claimCode,
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
});
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
document.getElementById('inputSection').style.display = 'none';
loadLeaderboard();
} catch (e) {}
};
async function loadLeaderboard() {
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
const entries = await res.json();
let html = "<h3>BESTENLISTE</h3>";
entries.forEach(e => {
const color = e.isMe ? "yellow" : "white";
html += `<div style="display:flex; justify-content:space-between; color:${color}; margin-bottom:5px;">
<span>#${e.rank} ${e.name}</span><span>${Math.floor(e.score/10)}</span></div>`;
});
document.getElementById('leaderboard').innerHTML = html;
}
async function loadStartScreenLeaderboard() {
try {
const listEl = document.getElementById('startLeaderboardList');
if (!listEl) return;
const res = await fetch('/api/leaderboard');
const entries = await res.json();
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Noch keine Scores.</div>"; return; }
let html = "";
entries.forEach(e => {
let icon = "#" + e.rank;
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
});
listEl.innerHTML = html;
} catch (e) {}
}
function compareState(clientObs, serverObs) {
// 1. Anzahl prüfen
if (clientObs.length !== serverObs.length) {
console.error(`🚨 ANZAHL MISMATCH! Client: ${clientObs.length}, Server: ${serverObs.length}`);
// ==========================================
// INPUT SENDEN
// ==========================================
function sendInput(type, action) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: "input",
input: action
}));
}
}
// Helper für die Tabelle
function generateSyncTable(label, clientList, serverList) {
if (!serverList) serverList = [];
console.log(`--- ${label} Analyse (Ping: ${Math.round(currentLatencyMs)}ms) ---`);
const report = [];
const maxLen = Math.max(clientObs.length, serverObs.length);
let hasMajorDrift = false;
const matchedServerIndices = new Set();
for (let i = 0; i < maxLen; i++) {
const cli = clientObs[i];
const srv = serverObs[i];
// 1. Parameter für Latenz-Korrektur berechnen
// Damit wir wissen: "Wo MÜSSTE das Server-Objekt auf dem Client sein?"
const msPerTick = 50; // Bei 20 TPS
let drift = 0;
let status = "✅ OK";
// Speed Schätzung (gleiche Formel wie in logic.js)
let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
if (debugSpeed > 36) debugSpeed = 36;
// Client Objekt vorbereiten
let cID = "---";
let cX = 0;
if (cli) {
cID = cli.def.id; // Struktur beachten: cli.def.id
cX = cli.x;
}
const speedPerMs = debugSpeed / msPerTick;
// Server Objekt vorbereiten
let sID = "---";
let sX = 0;
if (srv) {
sID = srv.id; // Struktur vom Server: srv.id
sX = srv.x;
}
// Pixel, die das Objekt wegen Ping weiter "links" sein müsste
const latencyPx = currentLatencyMs * speedPerMs;
// Vergleich
if (cli && srv) {
// IDs unterschiedlich? (z.B. Tisch vs Lehrer)
if (cID !== sID) {
status = "❌ ID ERROR";
hasMajorDrift = true;
} else {
drift = cX - sX;
if (Math.abs(drift) > SYNC_TOLERANCE) {
status = "⚠️ DRIFT";
hasMajorDrift = true;
}
// 2. Client Objekte durchgehen
clientList.forEach((cObj) => {
let bestMatch = null;
let bestDist = 9999;
let bestSIdx = -1;
// ID sicherstellen
const cID = cObj.def ? cObj.def.id : (cObj.id || "unknown");
// Passendes Server-Objekt suchen
serverList.forEach((sObj, sIdx) => {
if (matchedServerIndices.has(sIdx)) return;
const sID = sObj.id || "unknown";
// Match Kriterien:
// 1. Gleiche ID (oder Plattform)
// 2. Nähe (Wir vergleichen hier die korrigierte Position!)
const sPosCorrected = sObj.x - latencyPx;
const dist = Math.abs(cObj.x - sPosCorrected);
const isTypeMatch = (label === "Platforms") || (cID === sID);
// Toleranter Suchradius (500px), falls Drift groß ist
if (isTypeMatch && dist < bestDist && dist < 500) {
bestDist = dist;
bestMatch = sObj;
bestSIdx = sIdx;
}
} else {
status = "❌ MISSING";
hasMajorDrift = true;
});
// Datenzeile bauen
let serverXRaw = "---";
let serverXCorrected = "---";
let diffReal = "---";
let status = "👻 GHOST (Client only)";
if (bestMatch) {
matchedServerIndices.add(bestSIdx);
serverXRaw = bestMatch.x;
serverXCorrected = bestMatch.x - latencyPx; // Hier rechnen wir den Ping raus
// Der "Wahrs" Drift: Differenz nach Latenz-Abzug
diffReal = cObj.x - serverXCorrected;
// Status Bestimmung
const absDiff = Math.abs(diffReal);
if (absDiff < 20) status = "✅ PERFECT";
else if (absDiff < 60) status = "🆗 OK";
else if (absDiff < 150) status = "⚠️ DRIFT";
else status = "🔥 BROKEN";
}
// In Tabelle eintragen
report.push({
Index: i,
Status: status,
"C-ID": cID,
"S-ID": sID,
"C-Pos": cX.toFixed(1),
"S-Pos": sX.toFixed(1),
"Drift (px)": drift.toFixed(2)
"ID": cID,
"Client X": Math.round(cObj.x),
"Server X (Raw)": Math.round(serverXRaw),
"Server X (Sim)": Math.round(serverXCorrected), // Wo es sein sollte
"Diff (Real)": typeof diffReal === 'number' ? Math.round(diffReal) : "---",
"Status": status
});
}
});
// Nur loggen, wenn Fehler da sind oder alle 5 Sekunden (Tick 300)
if (hasMajorDrift || currentTick % 300 === 0) {
if (hasMajorDrift) console.warn("--- SYNC PROBLEME GEFUNDEN ---");
else console.log("--- Sync Check (Routine) ---");
// 3. Fehlende Server Objekte finden
serverList.forEach((sObj, sIdx) => {
if (!matchedServerIndices.has(sIdx)) {
// Prüfen, ob es vielleicht einfach noch unsichtbar ist (Zukunft)
const sPosCorrected = sObj.x - latencyPx;
let status = "❌ MISSING";
console.table(report); // Das erstellt eine super lesbare Tabelle im Browser
if (sPosCorrected > 850) status = "🔮 FUTURE (Buffer)"; // Noch rechts vom Screen
if (sPosCorrected < -100) status = "🗑️ OLD (Server lag)"; // Schon links raus
report.push({
"ID": sObj.id || "?",
"Client X": "---",
"Server X (Raw)": Math.round(sObj.x),
"Server X (Sim)": Math.round(sPosCorrected),
"Diff (Real)": "---",
"Status": status
});
}
});
// 4. Sortieren nach Position (links nach rechts)
report.sort((a, b) => {
const valA = (typeof a["Client X"] === 'number') ? a["Client X"] : a["Server X (Sim)"];
const valB = (typeof b["Client X"] === 'number') ? b["Client X"] : b["Server X (Sim)"];
return valA - valB;
});
if (report.length > 0) console.table(report);
else console.log("Leer.");
}
function sendPhysicsSync(y, vy) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: "sync",
y: y,
vy: vy,
tick: currentTick
}));
}
}
+88
View File
@@ -0,0 +1,88 @@
// Globale Partikel-Liste (muss in state.js bekannt sein oder hier exportiert)
// Wir nutzen die globale Variable 'particles' (fügen wir gleich in state.js hinzu)
class Particle {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.life = 1.0; // 1.0 = 100% Leben
this.type = type; // 'dust', 'sparkle', 'explosion'
// Zufällige Geschwindigkeit
const angle = Math.random() * Math.PI * 2;
let speed = Math.random() * 2;
if (type === 'dust') {
// Staub fliegt eher nach oben/hinten
this.vx = -2 + Math.random();
this.vy = -1 - Math.random();
this.decay = 0.05; // Verschwindet schnell
this.color = '#ddd';
this.size = Math.random() * 4 + 2;
}
else if (type === 'sparkle') {
// Münzen glitzern in alle Richtungen
speed = Math.random() * 4 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.decay = 0.03;
this.color = '#ffcc00';
this.size = Math.random() * 3 + 1;
}
else if (type === 'explosion') {
// Tod
speed = Math.random() * 6 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.decay = 0.02;
this.color = Math.random() > 0.5 ? '#ff4444' : '#ffaa00';
this.size = Math.random() * 6 + 3;
}
}
update() {
this.x += this.vx;
this.y += this.vy;
// Physik
if (this.type !== 'sparkle') this.vy += 0.2; // Schwerkraft für Staub/Explosion
// Reibung
this.vx *= 0.95;
this.vy *= 0.95;
this.life -= this.decay;
}
draw(ctx) {
ctx.globalAlpha = this.life;
ctx.fillStyle = this.color;
// Quadratische Partikel (schneller zu zeichnen)
ctx.fillRect(this.x, this.y, this.size, this.size);
ctx.globalAlpha = 1.0;
}
}
// --- API ---
function spawnParticles(x, y, type, count = 5) {
for(let i=0; i<count; i++) {
particles.push(new Particle(x, y, type));
}
}
function updateParticles() {
// Rückwärts loopen zum sicheren Löschen
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
if (particles[i].life <= 0) {
particles.splice(i, 1);
}
}
}
function drawParticles() {
particles.forEach(p => p.draw(ctx));
}
+142 -70
View File
@@ -1,113 +1,177 @@
// ==========================================
// RESIZE LOGIK (LETTERBOXING)
// ==========================================
function resize() {
// 1. INTERNE SPIEL-AUFLÖSUNG ERZWINGEN
// Das behebt den "Zoom/Nur Ecke sichtbar" Fehler
// 1. Interne Auflösung fixieren
canvas.width = GAME_WIDTH; // 800
canvas.height = GAME_HEIGHT; // 400
// 2. Verfügbaren Platz im Browser berechnen (Minus etwas Rand)
// 2. Verfügbaren Platz berechnen
const windowWidth = window.innerWidth - 20;
const windowHeight = window.innerHeight - 20;
const targetRatio = GAME_WIDTH / GAME_HEIGHT; // 2.0
const targetRatio = GAME_WIDTH / GAME_HEIGHT;
const windowRatio = windowWidth / windowHeight;
let finalWidth, finalHeight;
// 3. Letterboxing berechnen
// 3. Skalierung berechnen (Aspect Ratio erhalten)
if (windowRatio < targetRatio) {
// Screen ist schmaler (z.B. Handy Portrait) -> Breite limitiert
finalWidth = windowWidth;
finalHeight = windowWidth / targetRatio;
} else {
// Screen ist breiter (z.B. Desktop) -> Höhe limitiert
finalHeight = windowHeight;
finalWidth = finalHeight * targetRatio;
}
// 4. Größe auf den CONTAINER anwenden
// 4. Container Größe setzen (Canvas füllt Container via CSS)
if (container) {
container.style.width = `${Math.floor(finalWidth)}px`;
container.style.height = `${Math.floor(finalHeight)}px`;
}
// Hinweis: Wir setzen KEINE style.width/height auf das Canvas Element selbst.
// Das Canvas erbt "width: 100%; height: 100%" vom CSS und füllt den Container.
}
// Event Listener
window.addEventListener('resize', resize);
// Einmal sofort ausführen
resize();
// --- DRAWING ---
function drawGame() {
// ==========================================
// DRAWING LOOP (MIT INTERPOLATION)
// ==========================================
// alpha (0.0 bis 1.0) gibt an, wie weit wir zeitlich zwischen zwei Physik-Ticks sind.
function drawGame(alpha = 1.0) {
// 1. Canvas leeren
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// ===============================================
// HINTERGRUND
// ===============================================
let currentBg = null;
if (bgSprites.length > 0) {
// Wechselt alle 10.000 Punkte
const changeInterval = 10000;
const currentRawIndex = Math.floor(score / changeInterval);
if (currentRawIndex > maxRawBgIndex) {
maxRawBgIndex = currentRawIndex;
}
if (currentRawIndex > maxRawBgIndex) maxRawBgIndex = currentRawIndex;
const bgIndex = maxRawBgIndex % bgSprites.length;
currentBg = bgSprites[bgIndex];
}
if (currentBg && currentBg.complete && currentBg.naturalHeight !== 0) {
ctx.drawImage(currentBg, 0, 0, GAME_WIDTH, GAME_HEIGHT);
} else {
// Fallback
ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
// --- BODEN ---
// Halb-transparent, damit er über dem Hintergrund liegt
// ===============================================
// BODEN
// ===============================================
ctx.fillStyle = "rgba(60, 60, 60, 0.8)";
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
// --- HINDERNISSE ---
obstacles.forEach(obs => {
const img = sprites[obs.def.id];
// ===============================================
// PLATTFORMEN (Interpoliert)
// ===============================================
platforms.forEach(p => {
// Interpolierte X-Position
const rX = (p.prevX !== undefined) ? lerp(p.prevX, p.x, alpha) : p.x;
const rY = p.y;
// Prüfen ob Bild geladen ist
if (img && img.complete && img.naturalHeight !== 0) {
ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height);
} else {
// Fallback Farbe (Münzen Gold, Rest aus Config)
if (obs.def.type === "coin") ctx.fillStyle = "gold";
else ctx.fillStyle = obs.def.color || "red";
ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height);
}
if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech);
// Holz-Optik
ctx.fillStyle = "#5D4037";
ctx.fillRect(rX, rY, p.w, p.h);
ctx.fillStyle = "#8D6E63";
ctx.fillRect(rX, rY, p.w, 5); // Highlight oben
});
// --- DEBUG RAHMEN (Server Hitboxen) ---
// Grün im Spiel, Rot bei Tod
if (DEBUG_SYNC == true) {
ctx.strokeStyle = isGameOver ? "red" : "lime";
ctx.lineWidth = 2;
serverObstacles.forEach(srvObs => {
ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h);
});
// ===============================================
// HINDERNISSE (Interpoliert)
// ===============================================
obstacles.forEach(obs => {
const def = obs.def || {};
const img = sprites[def.id];
// Interpolation
const rX = (obs.prevX !== undefined) ? lerp(obs.prevX, obs.x, alpha) : obs.x;
const rY = obs.y;
// Hitbox Dimensionen
const hbw = def.width || obs.w || 30;
const hbh = def.height || obs.h || 30;
if (img && img.complete && img.naturalHeight !== 0) {
// --- BILD VORHANDEN ---
// Editor-Werte anwenden
const scale = def.imgScale || 1.0;
const offX = def.imgOffsetX || 0.0;
const offY = def.imgOffsetY || 0.0;
// 1. Skalierte Größe
const drawW = hbw * scale;
const drawH = hbh * scale;
// 2. Positionierung (Zentriert & Unten Bündig zur Hitbox)
const baseX = rX + (hbw - drawW) / 2;
const baseY = rY + (hbh - drawH);
// 3. Zeichnen
ctx.drawImage(img, baseX + offX, baseY + offY, drawW, drawH);
} else {
// --- FALLBACK (KEIN BILD) ---
// Magenta als Warnung, Gold für Coins
let color = "#FF00FF";
if (def.type === "coin") color = "gold";
else if (def.color) color = def.color;
ctx.fillStyle = color;
ctx.fillRect(rX, rY, hbw, hbh);
// Rahmen & Text
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2;
ctx.strokeRect(rX, rY, hbw, hbh);
ctx.fillStyle = "white"; ctx.font = "bold 10px monospace";
ctx.fillText(def.id || "?", rX, rY - 5);
}
// --- DEBUG HITBOX (Client) ---
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
ctx.strokeStyle = "rgba(0,255,0,0.5)"; // Grün transparent
ctx.lineWidth = 1;
ctx.strokeRect(rX, rY, hbw, hbh);
}
// Sprechblase
if(obs.speech) drawSpeechBubble(rX, rY, obs.speech);
});
// ===============================================
// DEBUG: SERVER STATE (Cyan)
// ===============================================
// Zeigt an, wo der Server die Objekte sieht (ohne Interpolation)
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
if (serverObstacles) {
ctx.strokeStyle = "cyan";
ctx.lineWidth = 1;
serverObstacles.forEach(sObj => {
// Wir müssen hier die Latenz-Korrektur aus network.js abziehen,
// um zu sehen, wo network.js sie hingeschoben hat?
// Nein, serverObstacles enthält die Rohdaten.
// Wenn wir wissen wollen, wo der Server "jetzt" ist, müssten wir schätzen.
// Wir zeichnen einfach Raw, das hinkt optisch meist hinterher.
ctx.strokeRect(sObj.x, sObj.y, sObj.w, sObj.h);
});
}
}
// ===============================================
// SPIELER (Interpoliert)
// ===============================================
// Interpolierte Y-Position
let rPlayerY = lerp(player.prevY !== undefined ? player.prevY : player.y, player.y, alpha);
// --- SPIELER ---
// Y-Position und Höhe anpassen für Ducken
const drawY = isCrouching ? player.y + 25 : player.y;
// Ducken Anpassung
const drawY = isCrouching ? rPlayerY + 25 : rPlayerY;
const drawH = isCrouching ? 25 : 50;
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
@@ -117,7 +181,16 @@ function drawGame() {
ctx.fillRect(player.x, drawY, player.w, drawH);
}
// --- HUD (Powerup Status oben links) ---
// ===============================================
// PARTIKEL (Visuelle Effekte)
// ===============================================
if (typeof drawParticles === 'function') {
drawParticles();
}
// ===============================================
// HUD (Statusanzeige)
// ===============================================
if (isGameRunning && !isGameOver) {
ctx.fillStyle = "black";
ctx.font = "bold 10px monospace";
@@ -128,30 +201,29 @@ function drawGame() {
if(hasBat) statusText += `⚾ BAT `;
if(bootTicks > 0) statusText += `👟 ${(bootTicks/60).toFixed(1)}s`;
// Drift Info (nur wenn Objekte da sind)
if (DEBUG_SYNC == true && length > 0 && serverObstacles.length > 0) {
const drift = Math.abs(obstacles[0].x - serverObstacles[0].x).toFixed(1);
statusText += ` | Drift: ${drift}px`; // Einkommentieren für Debugging
}
if(statusText !== "") {
ctx.fillText(statusText, 10, 40);
}
}
// --- GAME OVER OVERLAY ---
// ===============================================
// GAME OVER OVERLAY
// ===============================================
if (isGameOver) {
// Dunkler Schleier über alles
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);
}
}
// Sprechblasen Helper
// Helper: Sprechblase zeichnen
function drawSpeechBubble(x, y, text) {
const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH);
ctx.strokeRect(bX,bY,bW,bH);
ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center";
ctx.fillText(text, bX+bW/2, bY+20);
const bX = x - 20;
const bY = y - 40;
const bW = 120;
const bH = 30;
ctx.fillStyle = "white"; ctx.fillRect(bX, bY, bW, bH);
ctx.strokeStyle = "black"; ctx.lineWidth = 1; ctx.strokeRect(bX, bY, bW, bH);
ctx.fillStyle = "black"; ctx.font = "10px Arial"; ctx.textAlign = "center";
ctx.fillText(text, bX + bW/2, bY + 20);
}
+57 -29
View File
@@ -1,51 +1,79 @@
// Globale Status-Variablen
let gameConfig = null;
let isLoaded = false;
let isGameRunning = false;
let isGameOver = false;
let sessionID = null;
// ==========================================
// GLOBALE STATUS VARIABLEN
// ==========================================
let rng = null;
let score = 0;
let currentTick = 0;
let lastSentTick = 0;
let inputLog = [];
let isCrouching = false;
// --- Konfiguration & Flags ---
let gameConfig = null; // Wird von /api/config geladen
let isLoaded = false; // Sind Assets geladen?
let isGameRunning = false; // Läuft der Game Loop?
let isGameOver = false; // Ist der Spieler tot?
let sessionID = null; // UUID der aktuellen Session
// Powerups Client State
// --- NETZWERK & STREAMING (NEU) ---
let socket = null; // Die WebSocket Verbindung
let obstacleBuffer = []; // Warteschlange für kommende Hindernisse
let platformBuffer = []; // Warteschlange für kommende Plattformen
// --- SPIELZUSTAND ---
let score = 0; // Aktueller Punktestand (vom Server diktiert)
let currentTick = 0; // Zeit-Einheit des Spiels
// --- POWERUPS (Client Visuals) ---
let godModeLives = 0;
let hasBat = false;
let bootTicks = 0;
// Hintergrund
let currentBgIndex = 0;
let maxRawBgIndex = 0;
// --- HINTERGRUND ---
let maxRawBgIndex = 0; // Welcher Hintergrund wird gezeigt?
// Tick Time
// --- GAME LOOP TIMING ---
let lastTime = 0;
let accumulator = 0;
let lastPowerupTick = -9999;
let nextSpawnTick = 0;
// Grafiken
let sprites = {};
// --- GRAFIKEN ---
let sprites = {}; // Cache für Hindernis-Bilder
let playerSprite = new Image();
let bgSprite = new Image();
let bgSprites = [];
// Spiel-Objekte
let bgSprites = []; // Array der Hintergrund-Bilder
// --- ENTITIES (Render-Listen) ---
let player = {
x: 50, y: 300, w: 30, h: 50, color: "red",
vy: 0, grounded: false
x: 50,
y: 300,
w: 30,
h: 50,
color: "red",
vy: 0,
grounded: false,
prevY: 300
};
let particles = [];
// Diese Listen werden von logic.js aus dem Buffer gefüllt und von render.js gezeichnet
let obstacles = [];
let serverObstacles = [];
let platforms = [];
// HTML Elemente (Caching)
// Debug-Daten (optional, falls der Server Debug-Infos schickt)
let serverObstacles = [];
let serverPlatforms = [];
let currentLatencyMs = 0; // Aktuelle Latenz in Millisekunden
let pingInterval = null; // Timer für den Ping
// --- INPUT STATE ---
let isCrouching = false;
// ==========================================
// HTML ELEMENTE (Caching)
// ==========================================
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('game-container');
// UI Elemente
const startScreen = document.getElementById('startScreen');
const startBtn = document.getElementById('startBtn');
const loadingText = document.getElementById('loadingText');
const gameOverScreen = document.getElementById('gameOverScreen');
const gameOverScreen = document.getElementById('gameOverScreen');
const scoreDisplay = document.getElementById('score');
const highscoreDisplay = document.getElementById('localHighscore');
+28
View File
@@ -362,4 +362,32 @@ input {
@media screen and (orientation: portrait) {
#rotate-overlay { display: flex; }
#game-container { display: none !important; }
}
/* ... bestehende Styles ... */
#mute-btn {
position: fixed;
top: 10px;
left: 10px;
z-index: 100; /* Über allem */
background: rgba(0, 0, 0, 0.5);
border: 2px solid #555;
color: white;
font-size: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
padding: 0;
margin: 0; /* Override default button margin */
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
#mute-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: white;
}
+42 -1
View File
@@ -10,14 +10,46 @@ type ObstacleDef struct {
CanTalk bool `json:"canTalk"`
SpeechLines []string `json:"speechLines"`
YOffset float64 `json:"yOffset"`
ImgScale float64 `json:"imgScale"`
ImgOffsetX float64 `json:"imgOffsetX"`
ImgOffsetY float64 `json:"imgOffsetY"`
}
type ChunkObstacle struct {
ID string `json:"id"`
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"w"`
Height float64 `json:"h"`
Color string `json:"color"`
ImgScale float64 `json:"imgScale"`
ImgOffsetX float64 `json:"imgOffsetX"`
ImgOffsetY float64 `json:"imgOffsetY"`
}
type PlatformDef struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"w"`
Height float64 `json:"h"`
Type string `json:"type"`
}
type ChunkDef struct {
ID string `json:"id"`
Platforms []PlatformDef `json:"platforms"`
Obstacles []ChunkObstacle `json:"obstacles"`
TotalWidth int `json:"totalWidth"`
}
type GameConfig struct {
Obstacles []ObstacleDef `json:"obstacles"`
Backgrounds []string `json:"backgrounds"`
Chunks []ChunkDef `json:"chunks"`
}
// Dynamischer State
// Dynamischer State (Simulation)
type ActiveObstacle struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -27,6 +59,13 @@ type ActiveObstacle struct {
Height float64 `json:"h"`
}
type ActivePlatform struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"w"`
Height float64 `json:"h"`
}
// API Requests/Responses
type Input struct {
Tick int `json:"t"`
@@ -49,9 +88,11 @@ type ValidateResponse struct {
Status string `json:"status"`
VerifiedScore int `json:"verifiedScore"`
ServerObs []ActiveObstacle `json:"serverObs"`
ServerPlats []ActivePlatform `json:"serverPlats"`
PowerUps PowerUpState `json:"powerups"`
ServerTick int `json:"serverTick"`
NextSpawnTick int `json:"nextSpawnTick"`
RngState uint32 `json:"rngState"`
}
type StartResponse struct {
+308
View File
@@ -0,0 +1,308 @@
package main
import (
"log"
"math"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
ServerTickRate = 50 * time.Millisecond
BufferAhead = 60 // Puffergröße (Zukunft)
SpawnXStart = 2000.0 // Spawn Abstand
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Protokoll
type WSInputMsg struct {
Type string `json:"type"`
Input string `json:"input"`
Tick int `json:"tick"` // Optional: Client Timestamp für Ping
PosY float64 `json:"y"`
VelY float64 `json:"vy"`
}
type WSServerMsg struct {
Type string `json:"type"`
Obstacles []ActiveObstacle `json:"obstacles"`
Platforms []ActivePlatform `json:"platforms"`
ServerTick int `json:"serverTick"`
Score int `json:"score"`
PowerUps PowerUpState `json:"powerups"`
SessionID string `json:"sessionId"`
Ts int `json:"ts,omitempty"` // Für Pong
CurrentSpeed float64 `json:"currentSpeed"`
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// 1. Session Init
sessionID := "ws-" + time.Now().Format("150405999")
err = rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
"score": 0, // Startwert
"is_dead": 0,
"created_at": time.Now().Format("02.01.2006 15:04"),
}).Err()
if err != nil {
log.Println("Redis Init Fehler:", err)
return
}
rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour)
// Session ID senden
conn.WriteJSON(WSServerMsg{Type: "init", SessionID: sessionID})
state := SimState{
SessionID: sessionID,
RNG: NewRNG(time.Now().UnixNano()),
Score: 0,
Ticks: 0,
PosY: PlayerYBase,
NextSpawnTick: 0,
}
// Channel größer machen, damit bei Lag nichts blockiert
inputChan := make(chan WSInputMsg, 100)
closeChan := make(chan struct{})
// LESE-ROUTINE
go func() {
defer close(closeChan)
for {
var msg WSInputMsg
if err := conn.ReadJSON(&msg); err != nil {
return
}
inputChan <- msg
}
}()
// GAME LOOP (High Performance)
ticker := time.NewTicker(ServerTickRate)
defer ticker.Stop()
// Input State
var pendingJump bool
var isCrouching bool
generatedHeadTick := 0
for {
select {
case <-closeChan:
return // Client weg
// WICHTIG: Wir verarbeiten Inputs hier NICHT einzeln,
// sondern sammeln sie im Default-Case (siehe unten),
// oder nutzen eine nicht-blockierende Schleife.
// Aber für einfache Logik reicht select.
// Um "Input Lag" zu verhindern, lesen wir den Channel leer:
case <-ticker.C:
// A. INPUTS VERARBEITEN (Alle die angekommen sind!)
// Wir loopen solange durch den Channel, bis er leer ist.
InputLoop:
for {
select {
case msg := <-inputChan:
if msg.Type == "input" {
if msg.Input == "JUMP" {
pendingJump = true
}
if msg.Input == "DUCK_START" {
isCrouching = true
}
if msg.Input == "DUCK_END" {
isCrouching = false
}
if msg.Input == "DEATH" {
state.IsDead = true
}
}
if msg.Type == "ping" {
// Sofort Pong zurück (Performance wichtig!)
conn.WriteJSON(WSServerMsg{Type: "pong", Ts: msg.Tick})
}
if msg.Type == "sync" {
diff := math.Abs(state.PosY - msg.PosY)
if diff < 100.0 {
state.PosY = msg.PosY
state.VelY = msg.VelY
}
}
if msg.Type == "debug" {
spd := calculateSpeed(state.Ticks)
conn.WriteJSON(WSServerMsg{
Type: "debug_sync",
ServerTick: state.Ticks,
Obstacles: state.Obstacles,
Platforms: state.Platforms,
Score: state.Score,
CurrentSpeed: spd,
})
log.Printf("🐞 Debug Snapshot an Client gesendet (Tick %d)", state.Ticks)
}
default:
// Channel leer, weiter zur Physik
break InputLoop
}
}
// B. LIVE SIMULATION (1 Tick)
// Jetzt simulieren wir genau EINEN Frame (16ms)
state.Ticks++
state.Score++ // Score wächst mit der Zeit
currentSpeed := calculateSpeed(state.Ticks)
updatePhysics(&state, pendingJump, isCrouching, currentSpeed)
pendingJump = false // Jump Trigger reset
checkCollisions(&state, isCrouching, currentSpeed)
if state.IsDead {
// Score Persistieren
rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
"score": state.Score,
"is_dead": 1,
})
rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour)
conn.WriteJSON(WSServerMsg{Type: "dead", Score: state.Score})
return
}
moveWorld(&state, currentSpeed)
// C. STREAMING (Zukunft)
// Wir generieren nur, wenn der Puffer zur Neige geht
targetTick := state.Ticks + BufferAhead
var newObs []ActiveObstacle
var newPlats []ActivePlatform
// Um CPU zu sparen, generieren wir max 10 Ticks pro Frame nach
loops := 0
for generatedHeadTick < targetTick && loops < 10 {
generatedHeadTick++
loops++
o, p := generateFutureObjects(&state, generatedHeadTick, currentSpeed)
if len(o) > 0 {
newObs = append(newObs, o...)
state.Obstacles = append(state.Obstacles, o...)
}
if len(p) > 0 {
newPlats = append(newPlats, p...)
state.Platforms = append(state.Platforms, p...)
}
}
// D. SENDEN (Effizienz)
// Nur senden wenn Daten da sind ODER alle 15 Frames (Heartbeat/Score Sync)
if len(newObs) > 0 || len(newPlats) > 0 || state.Ticks%15 == 0 {
msg := WSServerMsg{
Type: "chunk",
ServerTick: state.Ticks,
Score: state.Score,
Obstacles: newObs,
Platforms: newPlats,
PowerUps: PowerUpState{
GodLives: state.GodLives, HasBat: state.HasBat, BootTicks: state.BootTicks,
},
}
conn.WriteJSON(msg)
}
}
}
}
// ... (generateFutureObjects bleibt gleich wie vorher) ...
func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstacle, []ActivePlatform) {
var createdObs []ActiveObstacle
var createdPlats []ActivePlatform
if s.NextSpawnTick == 0 {
s.NextSpawnTick = tick + 50
}
if tick >= s.NextSpawnTick {
spawnX := SpawnXStart
chunkCount := len(defaultConfig.Chunks)
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
chunk := defaultConfig.Chunks[idx]
for _, p := range chunk.Platforms {
createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height})
}
for _, o := range chunk.Obstacles {
createdObs = append(createdObs, ActiveObstacle{ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height})
}
width := chunk.TotalWidth
if width == 0 {
width = 2000
}
s.NextSpawnTick = tick + int(float64(width)/speed)
} else {
// Random Logic
gap := 400 + int(s.RNG.NextRange(0, 500))
s.NextSpawnTick = tick + int(float64(gap)/speed)
defs := defaultConfig.Obstacles
if len(defs) > 0 {
// Boss Check
isBoss := (tick % 1500) > 1200
var pool []ObstacleDef
for _, d := range defs {
if isBoss {
if d.ID == "principal" || d.ID == "trashcan" {
pool = append(pool, d)
}
} else {
if d.ID != "principal" {
pool = append(pool, d)
}
}
}
def := s.RNG.PickDef(pool)
if def != nil {
// RNG Calls to keep sync (optional now, but good practice)
if def.CanTalk && s.RNG.NextFloat() > 0.7 {
}
if def.Type == "powerup" && s.RNG.NextFloat() > 0.1 {
def = nil
}
if def != nil {
createdObs = append(createdObs, ActiveObstacle{
ID: def.ID, Type: def.Type, X: spawnX, Y: GroundY - def.Height - def.YOffset, Width: def.Width, Height: def.Height,
})
}
}
}
}
}
return createdObs, createdPlats
}