Merge pull request 'fix-game' (#17) from fix-debug into main
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m27s
Reviewed-on: #17
@@ -13,7 +13,7 @@ COPY static/js/ ./js/
|
|||||||
# 1. Zusammenfügen (Reihenfolge ist wichtig!)
|
# 1. Zusammenfügen (Reihenfolge ist wichtig!)
|
||||||
# 2. In IIFE (Function) wickeln für Kapselung (Sicherheit)
|
# 2. In IIFE (Function) wickeln für Kapselung (Sicherheit)
|
||||||
# 3. Minifizieren (Unlesbar machen)
|
# 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 \
|
&& echo "(function(){" > combined.js \
|
||||||
&& cat temp.js >> combined.js \
|
&& cat temp.js >> combined.js \
|
||||||
&& echo "})();" >> combined.js \
|
&& echo "})();" >> combined.js \
|
||||||
|
|||||||
46
config.go
@@ -2,20 +2,22 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Gravity = 0.6
|
Gravity = 1.8
|
||||||
JumpPower = -12.0
|
JumpPower = -20.0
|
||||||
HighJumpPower = -16.0
|
HighJumpPower = -28.0
|
||||||
GroundY = 350.0
|
GroundY = 350.0
|
||||||
PlayerHeight = 50.0
|
PlayerHeight = 50.0
|
||||||
PlayerYBase = GroundY - PlayerHeight
|
PlayerYBase = GroundY - PlayerHeight
|
||||||
BaseSpeed = 5.0
|
BaseSpeed = 15.0
|
||||||
GameWidth = 800.0
|
GameWidth = 800.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,17 +41,17 @@ func initGameConfig() {
|
|||||||
defaultConfig = GameConfig{
|
defaultConfig = GameConfig{
|
||||||
Obstacles: []ObstacleDef{
|
Obstacles: []ObstacleDef{
|
||||||
// --- HINDERNISSE ---
|
// --- HINDERNISSE ---
|
||||||
{ID: "desk", Type: "obstacle", Width: 40, Height: 30, Color: "#8B4513", Image: "desk1.png"},
|
{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", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}},
|
{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: "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!"}},
|
{ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal1.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}},
|
||||||
|
|
||||||
// --- COINS ---
|
// --- COINS ---
|
||||||
{ID: "coin0", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 40.0},
|
{ID: "coin0", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", ImgScale: 1.1, ImgOffsetY: 1},
|
||||||
{ID: "coin1", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 50.0},
|
{ID: "coin1", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", YOffset: 60, ImgScale: 1.1, ImgOffsetY: 1},
|
||||||
{ID: "coin2", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 60.0},
|
|
||||||
|
|
||||||
// --- POWERUPS ---
|
// --- POWERUPS ---
|
||||||
{ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god1.png", YOffset: 20.0}, // Godmode
|
{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"},
|
Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"},
|
||||||
}
|
}
|
||||||
log.Println("✅ Config mit Powerups geladen")
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
8
go.mod
@@ -2,9 +2,13 @@ module escape-teacher
|
|||||||
|
|
||||||
go 1.25.4
|
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 (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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
go.sum
@@ -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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
|||||||
54
handlers.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,9 +15,54 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"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) {
|
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")
|
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) {
|
func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -64,9 +110,7 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---> HIER RUFEN WIR JETZT DIE SIMULATION AUF <---
|
isDead, score, obstacles, platforms, powerUpState, serverTick, nextSpawnTick, finalRngState := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
|
||||||
isDead, score, obstacles, powerUpState, serverTick, nextSpawnTick := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
|
|
||||||
|
|
||||||
status := "alive"
|
status := "alive"
|
||||||
if isDead {
|
if isDead {
|
||||||
status = "dead"
|
status = "dead"
|
||||||
@@ -78,9 +122,11 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
|
|||||||
Status: status,
|
Status: status,
|
||||||
VerifiedScore: score,
|
VerifiedScore: score,
|
||||||
ServerObs: obstacles,
|
ServerObs: obstacles,
|
||||||
|
ServerPlats: platforms,
|
||||||
PowerUps: powerUpState,
|
PowerUps: powerUpState,
|
||||||
ServerTick: serverTick,
|
ServerTick: serverTick,
|
||||||
NextSpawnTick: nextSpawnTick,
|
NextSpawnTick: nextSpawnTick,
|
||||||
|
RngState: finalRngState,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
main.go
@@ -39,6 +39,7 @@ func main() {
|
|||||||
http.HandleFunc("/api/config", Logger(handleConfig))
|
http.HandleFunc("/api/config", Logger(handleConfig))
|
||||||
http.HandleFunc("/api/start", Logger(handleStart))
|
http.HandleFunc("/api/start", Logger(handleStart))
|
||||||
http.HandleFunc("/api/validate", Logger(handleValidate))
|
http.HandleFunc("/api/validate", Logger(handleValidate))
|
||||||
|
http.HandleFunc("/ws", handleWebSocket)
|
||||||
http.HandleFunc("/api/submit-name", Logger(handleSubmitName))
|
http.HandleFunc("/api/submit-name", Logger(handleSubmitName))
|
||||||
http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard))
|
http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard))
|
||||||
http.HandleFunc("/api/claim/delete", Logger(handleClaimDelete))
|
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/badwords", Logger(BasicAuth(handleAdminBadwords)))
|
||||||
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))
|
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))
|
||||||
http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction)))
|
http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction)))
|
||||||
|
http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks)))
|
||||||
|
http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage)))
|
||||||
|
http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage)))
|
||||||
|
|
||||||
log.Println("🦖 Server läuft auf :8080")
|
log.Println("🦖 Server läuft auf :8080")
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
|
|||||||
501
secure/editor.html
Normal 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
secure/obstacle_editor.html
Normal 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>
|
||||||
712
simulation.go
@@ -8,273 +8,491 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, PowerUpState, int, int) {
|
// --- INTERNE STATE STRUKTUR ---
|
||||||
posY := parseOr(vals["pos_y"], PlayerYBase)
|
type SimState struct {
|
||||||
velY := parseOr(vals["vel_y"], 0.0)
|
SessionID string
|
||||||
score := int(parseOr(vals["score"], 0))
|
Score int
|
||||||
ticksAlive := int(parseOr(vals["total_ticks"], 0))
|
Ticks int
|
||||||
rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64)
|
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))
|
// Powerups
|
||||||
hasBat := vals["p_has_bat"] == "1"
|
GodLives int
|
||||||
bootTicks := int(parseOr(vals["p_boot_ticks"], 0))
|
HasBat bool
|
||||||
|
BootTicks int
|
||||||
|
|
||||||
lastJumpDist := parseOr(vals["ac_last_dist"], 0.0)
|
// Spawning & RNG
|
||||||
suspicionScore := int(parseOr(vals["ac_suspicion"], 0))
|
NextSpawnTick int
|
||||||
|
RNG *PseudoRNG
|
||||||
|
|
||||||
rng := NewRNG(rngStateVal)
|
// Anti-Cheat
|
||||||
|
LastJumpDist float64
|
||||||
|
SuspicionScore int
|
||||||
|
}
|
||||||
|
|
||||||
var obstacles []ActiveObstacle
|
// ============================================================================
|
||||||
if val, ok := vals["obstacles"]; ok && val != "" {
|
// HAUPTFUNKTION
|
||||||
json.Unmarshal([]byte(val), &obstacles)
|
// ============================================================================
|
||||||
} else {
|
|
||||||
obstacles = []ActiveObstacle{}
|
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
|
jumpCount := 0
|
||||||
for _, inp := range inputs {
|
for _, inp := range inputs {
|
||||||
if inp.Act == "JUMP" {
|
if inp.Act == "JUMP" {
|
||||||
jumpCount++
|
jumpCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if jumpCount > 10 {
|
return jumpCount > 10
|
||||||
log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge (%d)", sessionID, jumpCount)
|
}
|
||||||
return true, score, obstacles, PowerUpState{}, ticksAlive, nextSpawnTick
|
|
||||||
|
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++ {
|
func checkJumpSuspicion(s *SimState) {
|
||||||
ticksAlive++
|
var distToObs float64 = -1.0
|
||||||
|
for _, o := range s.Obstacles {
|
||||||
currentSpeed := BaseSpeed + (float64(ticksAlive)/3000.0)*0.5
|
if o.X > 50.0 {
|
||||||
if currentSpeed > 12.0 {
|
distToObs = o.X - 50.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 {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if distToObs > 0 {
|
||||||
if suspicionScore > 15 {
|
diff := math.Abs(distToObs - s.LastJumpDist)
|
||||||
log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID)
|
if diff < 1.0 {
|
||||||
playerDead = true
|
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 {
|
func parseOr(s string, def float64) float64 {
|
||||||
|
|||||||
BIN
static/assets/baskeball.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 1.4 MiB |
BIN
static/assets/g-l.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
static/assets/h-l.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
static/assets/k-l-monitor.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
static/assets/k-l.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
static/assets/k-m.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
static/assets/m-l.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
static/assets/p-l.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
static/assets/pc-trash.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
static/assets/r-l.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
static/assets/sfx/coin.mp3
Normal file
BIN
static/assets/sfx/duck.mp3
Normal file
BIN
static/assets/sfx/hit.mp3
Normal file
BIN
static/assets/sfx/jump.mp3
Normal file
BIN
static/assets/sfx/music_loop.mp3
Normal file
BIN
static/assets/sfx/pickup.mp3
Normal file
BIN
static/assets/sfx/powerup.mp3
Normal file
BIN
static/assets/t-s.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
static/assets/w-l.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
@@ -8,13 +8,12 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<button id="mute-btn" onclick="toggleAudioClick()">🔊</button>
|
||||||
<div id="rotate-overlay">
|
<div id="rotate-overlay">
|
||||||
<div class="icon">📱↻</div>
|
<div class="icon">📱↻</div>
|
||||||
<p>Bitte Gerät drehen!</p>
|
<p>Bitte Gerät drehen!</p>
|
||||||
<small>Querformat benötigt</small>
|
<small>Querformat benötigt</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="game-container">
|
<div id="game-container">
|
||||||
<canvas id="gameCanvas"></canvas>
|
<canvas id="gameCanvas"></canvas>
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@
|
|||||||
<h2 style="color:yellow">MEINE BEWEISE</h2>
|
<h2 style="color:yellow">MEINE BEWEISE</h2>
|
||||||
<div id="codesList" style="font-size: 10px; line-height: 1.8;">
|
<div id="codesList" style="font-size: 10px; line-height: 1.8;">
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,6 +134,8 @@
|
|||||||
|
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/state.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/network.js"></script>
|
||||||
<script src="js/input.js"></script>
|
<script src="js/input.js"></script>
|
||||||
<script src="js/logic.js"></script>
|
<script src="js/logic.js"></script>
|
||||||
|
|||||||
57
static/js/audio.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
// Konstanten
|
// ==========================================
|
||||||
|
// SPIEL KONFIGURATION & KONSTANTEN
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Dimensionen (Muss zum Canvas passen)
|
||||||
const GAME_WIDTH = 800;
|
const GAME_WIDTH = 800;
|
||||||
const GAME_HEIGHT = 400;
|
const GAME_HEIGHT = 400;
|
||||||
const GRAVITY = 0.6;
|
|
||||||
const JUMP_POWER = -12;
|
// Physik (Muss exakt synchron zum Go-Server sein!)
|
||||||
const HIGH_JUMP_POWER = -16;
|
const GRAVITY = 1.8;
|
||||||
const GROUND_Y = 350;
|
const JUMP_POWER = -20.0; // Vorher -36.0 (Deutlich weniger!)
|
||||||
const BASE_SPEED = 5.0;
|
const HIGH_JUMP_POWER = -28.0;// Vorher -48.0 (Boots)
|
||||||
const CHUNK_SIZE = 60;
|
const GROUND_Y = 350; // Y-Position des Bodens
|
||||||
const TARGET_FPS = 60;
|
|
||||||
|
// Geschwindigkeit
|
||||||
|
const BASE_SPEED = 15.0;
|
||||||
|
|
||||||
|
// Game Loop Einstellungen
|
||||||
|
const TARGET_FPS = 20;
|
||||||
const MS_PER_TICK = 1000 / TARGET_FPS;
|
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 DEBUG_SYNC = true;
|
||||||
const SYNC_TOLERANCE = 5.0;
|
|
||||||
|
|
||||||
// RNG Klasse
|
function lerp(a, b, t) {
|
||||||
class PseudoRNG {
|
return a + (b - a) * t;
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,114 @@
|
|||||||
|
// ==========================================
|
||||||
|
// INPUT HANDLING (WEBSOCKET VERSION)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
function handleInput(action, active) {
|
function handleInput(action, active) {
|
||||||
if (isGameOver) { if(active) location.reload(); return; }
|
// 1. Game Over Reset
|
||||||
|
if (isGameOver) {
|
||||||
const relativeTick = currentTick - lastSentTick;
|
if(active) location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JUMP LOGIK
|
||||||
if (action === "JUMP" && active) {
|
if (action === "JUMP" && active) {
|
||||||
|
// Wir prüfen lokal, ob wir springen dürfen (Client Prediction)
|
||||||
if (player.grounded && !isCrouching) {
|
if (player.grounded && !isCrouching) {
|
||||||
|
|
||||||
|
// A. Sofort lokal anwenden (damit es sich direkt anfühlt)
|
||||||
player.vy = JUMP_POWER;
|
player.vy = JUMP_POWER;
|
||||||
player.grounded = false;
|
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) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
|
// Ignorieren, wenn User gerade Name in Highscore tippt
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true);
|
if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true);
|
||||||
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", 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) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Maus / Touch (Einfach)
|
||||||
window.addEventListener('mousedown', (e) => {
|
window.addEventListener('mousedown', (e) => {
|
||||||
|
// Nur Linksklick und nur auf dem Canvas
|
||||||
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
|
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touch Logic
|
// Touch (Swipe Gesten)
|
||||||
let touchStartY = 0;
|
let touchStartY = 0;
|
||||||
|
|
||||||
window.addEventListener('touchstart', (e) => {
|
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 });
|
}, { passive: false });
|
||||||
|
|
||||||
window.addEventListener('touchend', (e) => {
|
window.addEventListener('touchend', (e) => {
|
||||||
if(e.target === canvas) {
|
if(e.target === canvas) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const diff = e.changedTouches[0].clientY - touchStartY;
|
const touchEndY = e.changedTouches[0].clientY;
|
||||||
if (diff < -30) handleInput("JUMP", true);
|
const diff = touchEndY - touchStartY;
|
||||||
else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); }
|
|
||||||
else if (Math.abs(diff) < 10) handleInput("JUMP", true);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,172 +1,210 @@
|
|||||||
function updateGameLogic() {
|
function updateGameLogic() {
|
||||||
// 1. Input Logging (Ducken)
|
// ===============================================
|
||||||
if (isCrouching) {
|
// 1. GESCHWINDIGKEIT
|
||||||
inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" });
|
// ===============================================
|
||||||
}
|
// 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!)
|
updateParticles();
|
||||||
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
|
|
||||||
if (currentSpeed > 12.0) currentSpeed = 12.0;
|
|
||||||
|
|
||||||
// 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 originalHeight = 50;
|
||||||
const crouchHeight = 25;
|
const crouchHeight = 25;
|
||||||
|
|
||||||
|
// Hitbox & Y-Pos anpassen
|
||||||
player.h = isCrouching ? crouchHeight : originalHeight;
|
player.h = isCrouching ? crouchHeight : originalHeight;
|
||||||
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
|
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
|
||||||
|
|
||||||
|
// Alte Position (für One-Way Check)
|
||||||
|
const oldY = player.y;
|
||||||
|
|
||||||
|
// Physik
|
||||||
player.vy += GRAVITY;
|
player.vy += GRAVITY;
|
||||||
if (isCrouching && !player.grounded) player.vy += 2.0; // Fast Fall
|
if (isCrouching && !player.grounded) player.vy += 2.0;
|
||||||
player.y += player.vy;
|
|
||||||
|
|
||||||
if (player.y + originalHeight >= GROUND_Y) {
|
let newY = player.y + player.vy;
|
||||||
player.y = GROUND_Y - originalHeight;
|
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.vy = 0;
|
||||||
player.grounded = true;
|
landed = true;
|
||||||
} else {
|
|
||||||
player.grounded = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Hindernisse Bewegen & Kollision
|
if (currentTick % 10 === 0) {
|
||||||
let nextObstacles = [];
|
sendPhysicsSync(player.y, player.vy);
|
||||||
|
}
|
||||||
|
|
||||||
for (let obs of obstacles) {
|
player.y = newY;
|
||||||
obs.x -= currentSpeed;
|
player.grounded = landed;
|
||||||
|
|
||||||
// Aufräumen, wenn links raus
|
// ===============================================
|
||||||
if (obs.x + obs.def.width < -50.0) continue;
|
// 3. PUFFER BEWEGEN (STREAMING)
|
||||||
|
// ===============================================
|
||||||
|
|
||||||
// --- PASSED CHECK (Wichtig!) ---
|
obstacleBuffer.forEach(o => o.x -= currentSpeed);
|
||||||
// Wenn das Hindernis den Spieler schon passiert hat, ignorieren wir Kollisionen.
|
platformBuffer.forEach(p => p.x -= currentSpeed);
|
||||||
// Das verhindert "Geister-Treffer" von hinten durch CCD.
|
|
||||||
const paddingX = 10;
|
|
||||||
const realRightEdge = obs.x + obs.def.width - paddingX;
|
|
||||||
|
|
||||||
// Spieler ist bei 50. Wir geben 5px Puffer.
|
// Aufräumen (Links raus)
|
||||||
if (realRightEdge < 55) {
|
obstacleBuffer = obstacleBuffer.filter(o => o.x + (o.w||30) > -200); // Muss -200 sein
|
||||||
nextObstacles.push(obs); // Behalten, aber keine Kollisionsprüfung mehr
|
platformBuffer = platformBuffer.filter(p => p.x + (p.w||100) > -200); // Muss -200 sein
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// -------------------------------
|
|
||||||
|
|
||||||
// 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)) {
|
obstacles = [];
|
||||||
// A. COIN
|
platforms = [];
|
||||||
if (obs.def.type === "coin") {
|
const RENDER_LIMIT = 900;
|
||||||
score += 2000;
|
|
||||||
continue; // Entfernen
|
// Hitbox definieren (für lokale Prüfung)
|
||||||
}
|
const pHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
|
||||||
// B. POWERUP
|
|
||||||
else if (obs.def.type === "powerup") {
|
// --- HINDERNISSE ---
|
||||||
if (obs.def.id === "p_god") godModeLives = 3;
|
obstacleBuffer.forEach(obs => {
|
||||||
if (obs.def.id === "p_bat") hasBat = true;
|
// Nur verarbeiten, wenn im Sichtbereich
|
||||||
if (obs.def.id === "p_boot") bootTicks = 600;
|
if (obs.x < RENDER_LIMIT) {
|
||||||
lastPowerupTick = currentTick; // Für Sync merken
|
|
||||||
continue; // Entfernen
|
// A. Metadaten laden (falls noch nicht da)
|
||||||
}
|
if (!obs.def) {
|
||||||
// C. GEGNER
|
let baseDef = null;
|
||||||
else {
|
if(gameConfig && gameConfig.obstacles) {
|
||||||
if (hasBat && obs.def.type === "teacher") {
|
baseDef = gameConfig.obstacles.find(x => x.id === obs.id);
|
||||||
hasBat = false;
|
|
||||||
continue; // Zerstört
|
|
||||||
}
|
|
||||||
if (godModeLives > 0) {
|
|
||||||
godModeLives--;
|
|
||||||
continue; // Geschützt
|
|
||||||
}
|
}
|
||||||
|
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";
|
// B. Kollision prüfen (Nur wenn noch nicht eingesammelt)
|
||||||
if (!isGameOver) {
|
// Wir nutzen 'obs.collected' als Flag, damit wir Coins nicht doppelt zählen
|
||||||
sendChunk();
|
if (!obs.collected && !isGameOver) {
|
||||||
gameOver("Kollision");
|
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);
|
// C. Zur Render-Liste hinzufügen (Nur wenn NICHT eingesammelt)
|
||||||
}
|
if (!obs.collected) {
|
||||||
obstacles = nextObstacles;
|
obstacles.push(obs);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// --- PLATTFORMEN ---
|
||||||
if (def && def.type === "powerup") {
|
platformBuffer.forEach(plat => {
|
||||||
if (rng.nextFloat() > 0.1) def = null;
|
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) {
|
function checkCollision(p, obs) {
|
||||||
const paddingX = 10;
|
const def = obs.def || {};
|
||||||
const paddingY_Top = (obs.def.type === "teacher") ? 25 : 10;
|
const w = def.width || obs.w || 30;
|
||||||
const paddingY_Bottom = 5;
|
const h = def.height || obs.h || 30;
|
||||||
|
|
||||||
// Speed-basierte Hitbox-Erweiterung (CCD)
|
// Kleines Padding, damit es fair ist
|
||||||
// Wir schätzen den Speed hier, damit er ungefähr dem Server entspricht
|
const padX = 8;
|
||||||
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
|
const padY = (def.type === "teacher" || def.type === "principal") ? 20 : 5;
|
||||||
if (currentSpeed > 12.0) currentSpeed = 12.0;
|
|
||||||
|
|
||||||
const pLeft = p.x + paddingX;
|
// Koordinaten
|
||||||
const pRight = p.x + p.w - paddingX;
|
const pL = p.x + padX;
|
||||||
const pTop = p.y + paddingY_Top;
|
const pR = p.x + p.w - padX;
|
||||||
const pBottom = p.y + p.h - paddingY_Bottom;
|
const pT = p.y + padY;
|
||||||
|
const pB = p.y + p.h - 5;
|
||||||
|
|
||||||
const oLeft = obs.x + paddingX;
|
const oL = obs.x + padX;
|
||||||
// Wir erweitern die Hitbox nach rechts um die Geschwindigkeit,
|
const oR = obs.x + w - padX;
|
||||||
// um schnelle Durchschüsse zu verhindern.
|
const oT = obs.y + padY;
|
||||||
const oRight = obs.x + obs.def.width - paddingX + currentSpeed;
|
const oB = obs.y + h - 5;
|
||||||
|
|
||||||
const oTop = obs.y + paddingY_Top;
|
return (pR > oL && pL < oR && pB > oT && pT < oB);
|
||||||
const oBottom = obs.y + obs.def.height - paddingY_Bottom;
|
|
||||||
|
|
||||||
return (pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom);
|
|
||||||
}
|
}
|
||||||
@@ -1,247 +1,163 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// INIT & ASSETS
|
// 1. ASSETS LADEN
|
||||||
// ==========================================
|
// ==========================================
|
||||||
async function loadAssets() {
|
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) => {
|
const bgPromises = gameConfig.backgrounds.map((bgFile, index) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = "assets/" + bgFile;
|
img.src = "assets/" + bgFile;
|
||||||
img.onload = () => { bgSprites[index] = img; resolve(); };
|
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(); };
|
img.onerror = () => { resolve(); };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Player laden (kleiner Promise Wrapper)
|
const obsPromises = gameConfig.obstacles.map(def => {
|
||||||
const pPromise = new Promise(r => {
|
return new Promise((resolve) => {
|
||||||
playerSprite.onload = r;
|
if (!def.image) { resolve(); return; }
|
||||||
playerSprite.onerror = r;
|
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]);
|
await Promise.all([pPromise, ...bgPromises, ...obsPromises]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// START LOGIK
|
// 2. SPIEL STARTEN
|
||||||
// ==========================================
|
// ==========================================
|
||||||
window.startGameClick = async function() {
|
window.startGameClick = async function() {
|
||||||
if (!isLoaded) return;
|
if (!isLoaded) return;
|
||||||
|
|
||||||
startScreen.style.display = 'none';
|
startScreen.style.display = 'none';
|
||||||
document.body.classList.add('game-active');
|
document.body.classList.add('game-active');
|
||||||
try {
|
|
||||||
const sRes = await fetch('/api/start', {method:'POST'});
|
// Score Reset visuell
|
||||||
const sData = await sRes.json();
|
score = 0;
|
||||||
sessionID = sData.sessionId;
|
const scoreEl = document.getElementById('score');
|
||||||
rng = new PseudoRNG(sData.seed);
|
if (scoreEl) scoreEl.innerText = "0";
|
||||||
isGameRunning = true;
|
|
||||||
maxRawBgIndex = 0;
|
// WebSocket Start
|
||||||
lastTime = performance.now();
|
startMusic();
|
||||||
resize();
|
connectGame();
|
||||||
} catch(e) {
|
resize();
|
||||||
alert("Start Fehler: " + e.message);
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 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() {
|
window.submitScore = async function() {
|
||||||
const nameInput = document.getElementById('playerNameInput');
|
const nameInput = document.getElementById('playerNameInput');
|
||||||
const name = nameInput.value;
|
const name = nameInput.value.trim();
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
if (!name) return alert("Namen eingeben!");
|
if (!name) return alert("Bitte Namen eingeben!");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/submit-name', {
|
const res = await fetch('/api/submit-name', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Code lokal speichern (Claims)
|
||||||
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||||
myClaims.push({
|
myClaims.push({
|
||||||
name: name, score: Math.floor(score / 10), code: data.claimCode,
|
name: name,
|
||||||
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
|
score: Math.floor(score / 10),
|
||||||
|
code: data.claimCode,
|
||||||
|
date: new Date().toLocaleString('de-DE'),
|
||||||
|
sessionId: sessionID
|
||||||
});
|
});
|
||||||
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
||||||
|
|
||||||
|
// UI Update
|
||||||
document.getElementById('inputSection').style.display = 'none';
|
document.getElementById('inputSection').style.display = 'none';
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
alert(`Gespeichert! Code: ${data.claimCode}`);
|
alert(`Gespeichert! Dein Code: ${data.claimCode}`);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Fehler: " + e.message);
|
console.error(e);
|
||||||
|
alert("Fehler beim Speichern: " + e.message);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// Bestenliste laden (Game Over Screen)
|
||||||
// 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!"); }
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadLeaderboard() {
|
async function loadLeaderboard() {
|
||||||
try {
|
try {
|
||||||
|
// sessionID wird mitgesendet, um den eigenen Eintrag zu markieren
|
||||||
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
|
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
|
||||||
const entries = await res.json();
|
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 => {
|
entries.forEach(e => {
|
||||||
const color = e.isMe ? "yellow" : "white";
|
const color = e.isMe ? "cyan" : "white"; // Eigener Name in Cyan
|
||||||
const bgStyle = e.isMe ? "background:rgba(255,255,0,0.1);" : "";
|
const bgStyle = e.isMe ? "background:rgba(0,255,255,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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} margin-bottom:2px;">
|
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} display:flex; justify-content:space-between; color:${color}; font-size:12px;">
|
||||||
<div style="display:flex; justify-content:space-between; color:${color};">
|
<span>#${e.rank} ${e.name}</span>
|
||||||
<span>#${e.rank} ${e.name.toUpperCase()}</span>
|
<span>${Math.floor(e.score/10)}</span>
|
||||||
<span>${Math.floor(e.score/10)}</span>
|
|
||||||
</div>
|
|
||||||
${infoText}
|
|
||||||
</div>`;
|
</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;
|
document.getElementById('leaderboard').innerHTML = html;
|
||||||
} catch(e) { console.error(e); }
|
} catch(e) {
|
||||||
}
|
console.error("Leaderboard 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DER FIXIERTE GAME LOOP
|
// 4. GAME LOOP
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function gameLoop(timestamp) {
|
function gameLoop(timestamp) {
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
|
|
||||||
// 1. Wenn Assets noch nicht da sind, machen wir gar nichts
|
|
||||||
if (!isLoaded) return;
|
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 (isGameRunning && !isGameOver) {
|
||||||
|
|
||||||
if (!lastTime) lastTime = timestamp;
|
if (!lastTime) lastTime = timestamp;
|
||||||
const deltaTime = timestamp - lastTime;
|
const deltaTime = timestamp - lastTime;
|
||||||
lastTime = timestamp;
|
lastTime = timestamp;
|
||||||
@@ -254,28 +170,34 @@ function gameLoop(timestamp) {
|
|||||||
updateGameLogic();
|
updateGameLogic();
|
||||||
currentTick++;
|
currentTick++;
|
||||||
score++;
|
score++;
|
||||||
if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
|
|
||||||
accumulator -= MS_PER_TICK;
|
accumulator -= MS_PER_TICK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alpha = accumulator / MS_PER_TICK;
|
||||||
|
|
||||||
|
// Score im HUD
|
||||||
const scoreEl = document.getElementById('score');
|
const scoreEl = document.getElementById('score');
|
||||||
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
|
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. RENDERING (IMMER!)
|
drawGame(isGameRunning ? accumulator / MS_PER_TICK : 1.0);
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. INIT
|
||||||
|
// ==========================================
|
||||||
async function initGame() {
|
async function initGame() {
|
||||||
try {
|
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 loadAssets();
|
||||||
await loadStartScreenLeaderboard();
|
await loadStartScreenLeaderboard();
|
||||||
|
|
||||||
|
if (typeof getMuteState === 'function') {
|
||||||
|
updateMuteIcon(getMuteState());
|
||||||
|
}
|
||||||
|
|
||||||
isLoaded = true;
|
isLoaded = true;
|
||||||
if(loadingText) loadingText.style.display = 'none';
|
if(loadingText) loadingText.style.display = 'none';
|
||||||
if(startBtn) startBtn.style.display = 'inline-block';
|
if(startBtn) startBtn.style.display = 'inline-block';
|
||||||
@@ -284,10 +206,7 @@ async function initGame() {
|
|||||||
const hsEl = document.getElementById('localHighscore');
|
const hsEl = document.getElementById('localHighscore');
|
||||||
if(hsEl) hsEl.innerText = savedHighscore;
|
if(hsEl) hsEl.innerText = savedHighscore;
|
||||||
|
|
||||||
// Loop starten (mit dummy timestamp start)
|
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
|
|
||||||
// Initiales Zeichnen erzwingen (damit Hintergrund sofort da ist)
|
|
||||||
drawGame();
|
drawGame();
|
||||||
|
|
||||||
} catch(e) {
|
} 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();
|
initGame();
|
||||||
@@ -1,199 +1,377 @@
|
|||||||
async function sendChunk() {
|
// ==========================================
|
||||||
const ticksToSend = currentTick - lastSentTick;
|
// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
|
||||||
if (ticksToSend <= 0) return;
|
// ==========================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 = {
|
// Ping Timer stoppen falls aktiv
|
||||||
sessionId: sessionID,
|
if (typeof pingInterval !== 'undefined' && pingInterval) {
|
||||||
inputs: [...inputLog],
|
clearInterval(pingInterval);
|
||||||
totalTicks: ticksToSend
|
}
|
||||||
|
|
||||||
|
// 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 = [];
|
// --- 2. NACHRICHT VOM SERVER ---
|
||||||
lastSentTick = currentTick;
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
try {
|
// A. PONG (Latenzmessung)
|
||||||
const res = await fetch('/api/validate', {
|
if (msg.type === "pong") {
|
||||||
method: 'POST',
|
const now = Date.now();
|
||||||
headers: {'Content-Type': 'application/json'},
|
const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
// Round Trip Time (Hin + Zurück)
|
||||||
|
const rtt = now - sentTime;
|
||||||
|
|
||||||
// Update für visuelles Debugging
|
// One Way Latency (Latenz in eine Richtung)
|
||||||
if (data.serverObs) {
|
const latency = rtt / 2;
|
||||||
serverObstacles = data.serverObs;
|
|
||||||
|
|
||||||
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
|
// Glätten (Exponential Moving Average), damit Werte nicht springen
|
||||||
compareState(snapshotobstacles, data.serverObs);
|
// Wenn es der erste Wert ist, nehmen wir ihn direkt.
|
||||||
}
|
if (currentLatencyMs === 0) {
|
||||||
|
currentLatencyMs = latency;
|
||||||
if (data.powerups) {
|
|
||||||
const sTick = data.serverTick;
|
|
||||||
|
|
||||||
if (lastPowerupTick > sTick) {
|
|
||||||
} else {
|
} else {
|
||||||
godModeLives = data.powerups.godLives;
|
// 90% alter Wert, 10% neuer Wert
|
||||||
hasBat = data.powerups.hasBat;
|
currentLatencyMs = (currentLatencyMs * 0.9) + (latency * 0.1);
|
||||||
bootTicks = data.powerups.bootTicks;
|
}
|
||||||
|
|
||||||
|
// 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 (msg.type === "init") {
|
||||||
if (data.NextSpawnTick) {
|
console.log("📩 INIT EMPFANGEN:", msg); // <--- DEBUG LOG
|
||||||
if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
|
|
||||||
console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
|
if (msg.sessionId) {
|
||||||
nextSpawnTick = data.nextSpawnTick;
|
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") {
|
if (msg.score) score = msg.score;
|
||||||
console.error("💀 SERVER KILL", data);
|
|
||||||
gameOver("Vom Server gestoppt");
|
// Verbindung sauber trennen
|
||||||
} else {
|
socket.close();
|
||||||
const sScore = data.verifiedScore;
|
if (pingInterval) clearInterval(pingInterval);
|
||||||
// Score Korrektur
|
|
||||||
if (Math.abs(score - sScore) > 200) {
|
gameOver("Vom Server gestoppt");
|
||||||
console.warn(`⚠️ SCORE DRIFT: Client=${score} Server=${sScore}`);
|
|
||||||
score = sScore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
// --- 3. VERBINDUNG GETRENNT ---
|
||||||
console.error("Netzwerkfehler:", e);
|
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');
|
// INPUT SENDEN
|
||||||
const name = nameInput.value;
|
// ==========================================
|
||||||
const btn = document.getElementById('submitBtn');
|
function sendInput(type, action) {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
if (!name) return alert("Namen eingeben!");
|
socket.send(JSON.stringify({
|
||||||
btn.disabled = true;
|
type: "input",
|
||||||
|
input: action
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 report = [];
|
||||||
const maxLen = Math.max(clientObs.length, serverObs.length);
|
const matchedServerIndices = new Set();
|
||||||
let hasMajorDrift = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxLen; i++) {
|
// 1. Parameter für Latenz-Korrektur berechnen
|
||||||
const cli = clientObs[i];
|
// Damit wir wissen: "Wo MÜSSTE das Server-Objekt auf dem Client sein?"
|
||||||
const srv = serverObs[i];
|
const msPerTick = 50; // Bei 20 TPS
|
||||||
|
|
||||||
let drift = 0;
|
// Speed Schätzung (gleiche Formel wie in logic.js)
|
||||||
let status = "✅ OK";
|
let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
|
||||||
|
if (debugSpeed > 36) debugSpeed = 36;
|
||||||
|
|
||||||
// Client Objekt vorbereiten
|
const speedPerMs = debugSpeed / msPerTick;
|
||||||
let cID = "---";
|
|
||||||
let cX = 0;
|
|
||||||
if (cli) {
|
|
||||||
cID = cli.def.id; // Struktur beachten: cli.def.id
|
|
||||||
cX = cli.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server Objekt vorbereiten
|
// Pixel, die das Objekt wegen Ping weiter "links" sein müsste
|
||||||
let sID = "---";
|
const latencyPx = currentLatencyMs * speedPerMs;
|
||||||
let sX = 0;
|
|
||||||
if (srv) {
|
|
||||||
sID = srv.id; // Struktur vom Server: srv.id
|
|
||||||
sX = srv.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vergleich
|
// 2. Client Objekte durchgehen
|
||||||
if (cli && srv) {
|
clientList.forEach((cObj) => {
|
||||||
// IDs unterschiedlich? (z.B. Tisch vs Lehrer)
|
let bestMatch = null;
|
||||||
if (cID !== sID) {
|
let bestDist = 9999;
|
||||||
status = "❌ ID ERROR";
|
let bestSIdx = -1;
|
||||||
hasMajorDrift = true;
|
|
||||||
} else {
|
// ID sicherstellen
|
||||||
drift = cX - sX;
|
const cID = cObj.def ? cObj.def.id : (cObj.id || "unknown");
|
||||||
if (Math.abs(drift) > SYNC_TOLERANCE) {
|
|
||||||
status = "⚠️ DRIFT";
|
// Passendes Server-Objekt suchen
|
||||||
hasMajorDrift = true;
|
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({
|
report.push({
|
||||||
Index: i,
|
"ID": cID,
|
||||||
Status: status,
|
"Client X": Math.round(cObj.x),
|
||||||
"C-ID": cID,
|
"Server X (Raw)": Math.round(serverXRaw),
|
||||||
"S-ID": sID,
|
"Server X (Sim)": Math.round(serverXCorrected), // Wo es sein sollte
|
||||||
"C-Pos": cX.toFixed(1),
|
"Diff (Real)": typeof diffReal === 'number' ? Math.round(diffReal) : "---",
|
||||||
"S-Pos": sX.toFixed(1),
|
"Status": status
|
||||||
"Drift (px)": drift.toFixed(2)
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
// Nur loggen, wenn Fehler da sind oder alle 5 Sekunden (Tick 300)
|
// 3. Fehlende Server Objekte finden
|
||||||
if (hasMajorDrift || currentTick % 300 === 0) {
|
serverList.forEach((sObj, sIdx) => {
|
||||||
if (hasMajorDrift) console.warn("--- SYNC PROBLEME GEFUNDEN ---");
|
if (!matchedServerIndices.has(sIdx)) {
|
||||||
else console.log("--- Sync Check (Routine) ---");
|
// 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
static/js/particles.js
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,113 +1,177 @@
|
|||||||
|
// ==========================================
|
||||||
|
// RESIZE LOGIK (LETTERBOXING)
|
||||||
|
// ==========================================
|
||||||
function resize() {
|
function resize() {
|
||||||
// 1. INTERNE SPIEL-AUFLÖSUNG ERZWINGEN
|
// 1. Interne Auflösung fixieren
|
||||||
// Das behebt den "Zoom/Nur Ecke sichtbar" Fehler
|
|
||||||
canvas.width = GAME_WIDTH; // 800
|
canvas.width = GAME_WIDTH; // 800
|
||||||
canvas.height = GAME_HEIGHT; // 400
|
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 windowWidth = window.innerWidth - 20;
|
||||||
const windowHeight = window.innerHeight - 20;
|
const windowHeight = window.innerHeight - 20;
|
||||||
|
|
||||||
const targetRatio = GAME_WIDTH / GAME_HEIGHT; // 2.0
|
const targetRatio = GAME_WIDTH / GAME_HEIGHT;
|
||||||
const windowRatio = windowWidth / windowHeight;
|
const windowRatio = windowWidth / windowHeight;
|
||||||
|
|
||||||
let finalWidth, finalHeight;
|
let finalWidth, finalHeight;
|
||||||
|
|
||||||
// 3. Letterboxing berechnen
|
// 3. Skalierung berechnen (Aspect Ratio erhalten)
|
||||||
if (windowRatio < targetRatio) {
|
if (windowRatio < targetRatio) {
|
||||||
// Screen ist schmaler (z.B. Handy Portrait) -> Breite limitiert
|
|
||||||
finalWidth = windowWidth;
|
finalWidth = windowWidth;
|
||||||
finalHeight = windowWidth / targetRatio;
|
finalHeight = windowWidth / targetRatio;
|
||||||
} else {
|
} else {
|
||||||
// Screen ist breiter (z.B. Desktop) -> Höhe limitiert
|
|
||||||
finalHeight = windowHeight;
|
finalHeight = windowHeight;
|
||||||
finalWidth = finalHeight * targetRatio;
|
finalWidth = finalHeight * targetRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Größe auf den CONTAINER anwenden
|
// 4. Container Größe setzen (Canvas füllt Container via CSS)
|
||||||
if (container) {
|
if (container) {
|
||||||
container.style.width = `${Math.floor(finalWidth)}px`;
|
container.style.width = `${Math.floor(finalWidth)}px`;
|
||||||
container.style.height = `${Math.floor(finalHeight)}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);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
// Einmal sofort ausführen
|
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
|
|
||||||
// --- DRAWING ---
|
// ==========================================
|
||||||
|
// DRAWING LOOP (MIT INTERPOLATION)
|
||||||
function drawGame() {
|
// ==========================================
|
||||||
|
// 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);
|
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
|
||||||
|
// ===============================================
|
||||||
|
// HINTERGRUND
|
||||||
|
// ===============================================
|
||||||
let currentBg = null;
|
let currentBg = null;
|
||||||
|
|
||||||
if (bgSprites.length > 0) {
|
if (bgSprites.length > 0) {
|
||||||
|
// Wechselt alle 10.000 Punkte
|
||||||
const changeInterval = 10000;
|
const changeInterval = 10000;
|
||||||
|
|
||||||
const currentRawIndex = Math.floor(score / changeInterval);
|
const currentRawIndex = Math.floor(score / changeInterval);
|
||||||
|
if (currentRawIndex > maxRawBgIndex) maxRawBgIndex = currentRawIndex;
|
||||||
if (currentRawIndex > maxRawBgIndex) {
|
|
||||||
maxRawBgIndex = currentRawIndex;
|
|
||||||
}
|
|
||||||
const bgIndex = maxRawBgIndex % bgSprites.length;
|
const bgIndex = maxRawBgIndex % bgSprites.length;
|
||||||
|
|
||||||
currentBg = bgSprites[bgIndex];
|
currentBg = bgSprites[bgIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (currentBg && currentBg.complete && currentBg.naturalHeight !== 0) {
|
if (currentBg && currentBg.complete && currentBg.naturalHeight !== 0) {
|
||||||
ctx.drawImage(currentBg, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
ctx.drawImage(currentBg, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
|
||||||
ctx.fillStyle = "#f0f0f0";
|
ctx.fillStyle = "#f0f0f0";
|
||||||
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
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.fillStyle = "rgba(60, 60, 60, 0.8)";
|
||||||
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
|
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
|
||||||
|
|
||||||
// --- HINDERNISSE ---
|
// ===============================================
|
||||||
obstacles.forEach(obs => {
|
// PLATTFORMEN (Interpoliert)
|
||||||
const img = sprites[obs.def.id];
|
// ===============================================
|
||||||
|
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
|
// Holz-Optik
|
||||||
if (img && img.complete && img.naturalHeight !== 0) {
|
ctx.fillStyle = "#5D4037";
|
||||||
ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height);
|
ctx.fillRect(rX, rY, p.w, p.h);
|
||||||
} else {
|
ctx.fillStyle = "#8D6E63";
|
||||||
// Fallback Farbe (Münzen Gold, Rest aus Config)
|
ctx.fillRect(rX, rY, p.w, 5); // Highlight oben
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- DEBUG RAHMEN (Server Hitboxen) ---
|
// ===============================================
|
||||||
// Grün im Spiel, Rot bei Tod
|
// HINDERNISSE (Interpoliert)
|
||||||
if (DEBUG_SYNC == true) {
|
// ===============================================
|
||||||
ctx.strokeStyle = isGameOver ? "red" : "lime";
|
obstacles.forEach(obs => {
|
||||||
ctx.lineWidth = 2;
|
const def = obs.def || {};
|
||||||
serverObstacles.forEach(srvObs => {
|
const img = sprites[def.id];
|
||||||
ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h);
|
|
||||||
});
|
// 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 ---
|
// Ducken Anpassung
|
||||||
// Y-Position und Höhe anpassen für Ducken
|
const drawY = isCrouching ? rPlayerY + 25 : rPlayerY;
|
||||||
const drawY = isCrouching ? player.y + 25 : player.y;
|
|
||||||
const drawH = isCrouching ? 25 : 50;
|
const drawH = isCrouching ? 25 : 50;
|
||||||
|
|
||||||
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
|
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
|
||||||
@@ -117,7 +181,16 @@ function drawGame() {
|
|||||||
ctx.fillRect(player.x, drawY, player.w, drawH);
|
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) {
|
if (isGameRunning && !isGameOver) {
|
||||||
ctx.fillStyle = "black";
|
ctx.fillStyle = "black";
|
||||||
ctx.font = "bold 10px monospace";
|
ctx.font = "bold 10px monospace";
|
||||||
@@ -128,30 +201,29 @@ function drawGame() {
|
|||||||
if(hasBat) statusText += `⚾ BAT `;
|
if(hasBat) statusText += `⚾ BAT `;
|
||||||
if(bootTicks > 0) statusText += `👟 ${(bootTicks/60).toFixed(1)}s`;
|
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 !== "") {
|
if(statusText !== "") {
|
||||||
ctx.fillText(statusText, 10, 40);
|
ctx.fillText(statusText, 10, 40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GAME OVER OVERLAY ---
|
// ===============================================
|
||||||
|
// GAME OVER OVERLAY
|
||||||
|
// ===============================================
|
||||||
if (isGameOver) {
|
if (isGameOver) {
|
||||||
// Dunkler Schleier über alles
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||||
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);
|
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprechblasen Helper
|
// Helper: Sprechblase zeichnen
|
||||||
function drawSpeechBubble(x, y, text) {
|
function drawSpeechBubble(x, y, text) {
|
||||||
const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
|
const bX = x - 20;
|
||||||
ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH);
|
const bY = y - 40;
|
||||||
ctx.strokeRect(bX,bY,bW,bH);
|
const bW = 120;
|
||||||
ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center";
|
const bH = 30;
|
||||||
ctx.fillText(text, bX+bW/2, bY+20);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,79 @@
|
|||||||
// Globale Status-Variablen
|
// ==========================================
|
||||||
let gameConfig = null;
|
// GLOBALE STATUS VARIABLEN
|
||||||
let isLoaded = false;
|
// ==========================================
|
||||||
let isGameRunning = false;
|
|
||||||
let isGameOver = false;
|
|
||||||
let sessionID = null;
|
|
||||||
|
|
||||||
let rng = null;
|
// --- Konfiguration & Flags ---
|
||||||
let score = 0;
|
let gameConfig = null; // Wird von /api/config geladen
|
||||||
let currentTick = 0;
|
let isLoaded = false; // Sind Assets geladen?
|
||||||
let lastSentTick = 0;
|
let isGameRunning = false; // Läuft der Game Loop?
|
||||||
let inputLog = [];
|
let isGameOver = false; // Ist der Spieler tot?
|
||||||
let isCrouching = false;
|
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 godModeLives = 0;
|
||||||
let hasBat = false;
|
let hasBat = false;
|
||||||
let bootTicks = 0;
|
let bootTicks = 0;
|
||||||
|
|
||||||
// Hintergrund
|
// --- HINTERGRUND ---
|
||||||
let currentBgIndex = 0;
|
let maxRawBgIndex = 0; // Welcher Hintergrund wird gezeigt?
|
||||||
let maxRawBgIndex = 0;
|
|
||||||
|
|
||||||
// Tick Time
|
// --- GAME LOOP TIMING ---
|
||||||
let lastTime = 0;
|
let lastTime = 0;
|
||||||
let accumulator = 0;
|
let accumulator = 0;
|
||||||
let lastPowerupTick = -9999;
|
|
||||||
let nextSpawnTick = 0;
|
|
||||||
|
|
||||||
// Grafiken
|
// --- GRAFIKEN ---
|
||||||
let sprites = {};
|
let sprites = {}; // Cache für Hindernis-Bilder
|
||||||
let playerSprite = new Image();
|
let playerSprite = new Image();
|
||||||
let bgSprite = new Image();
|
let bgSprites = []; // Array der Hintergrund-Bilder
|
||||||
let bgSprites = [];
|
|
||||||
// Spiel-Objekte
|
// --- ENTITIES (Render-Listen) ---
|
||||||
let player = {
|
let player = {
|
||||||
x: 50, y: 300, w: 30, h: 50, color: "red",
|
x: 50,
|
||||||
vy: 0, grounded: false
|
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 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 canvas = document.getElementById('gameCanvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const container = document.getElementById('game-container');
|
const container = document.getElementById('game-container');
|
||||||
|
|
||||||
|
// UI Elemente
|
||||||
const startScreen = document.getElementById('startScreen');
|
const startScreen = document.getElementById('startScreen');
|
||||||
const startBtn = document.getElementById('startBtn');
|
const startBtn = document.getElementById('startBtn');
|
||||||
const loadingText = document.getElementById('loadingText');
|
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');
|
||||||
@@ -363,3 +363,31 @@ input {
|
|||||||
#rotate-overlay { display: flex; }
|
#rotate-overlay { display: flex; }
|
||||||
#game-container { display: none !important; }
|
#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;
|
||||||
|
}
|
||||||
43
types.go
@@ -10,14 +10,46 @@ type ObstacleDef struct {
|
|||||||
CanTalk bool `json:"canTalk"`
|
CanTalk bool `json:"canTalk"`
|
||||||
SpeechLines []string `json:"speechLines"`
|
SpeechLines []string `json:"speechLines"`
|
||||||
YOffset float64 `json:"yOffset"`
|
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 {
|
type GameConfig struct {
|
||||||
Obstacles []ObstacleDef `json:"obstacles"`
|
Obstacles []ObstacleDef `json:"obstacles"`
|
||||||
Backgrounds []string `json:"backgrounds"`
|
Backgrounds []string `json:"backgrounds"`
|
||||||
|
Chunks []ChunkDef `json:"chunks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamischer State
|
// Dynamischer State (Simulation)
|
||||||
type ActiveObstacle struct {
|
type ActiveObstacle struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -27,6 +59,13 @@ type ActiveObstacle struct {
|
|||||||
Height float64 `json:"h"`
|
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
|
// API Requests/Responses
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Tick int `json:"t"`
|
Tick int `json:"t"`
|
||||||
@@ -49,9 +88,11 @@ type ValidateResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
VerifiedScore int `json:"verifiedScore"`
|
VerifiedScore int `json:"verifiedScore"`
|
||||||
ServerObs []ActiveObstacle `json:"serverObs"`
|
ServerObs []ActiveObstacle `json:"serverObs"`
|
||||||
|
ServerPlats []ActivePlatform `json:"serverPlats"`
|
||||||
PowerUps PowerUpState `json:"powerups"`
|
PowerUps PowerUpState `json:"powerups"`
|
||||||
ServerTick int `json:"serverTick"`
|
ServerTick int `json:"serverTick"`
|
||||||
NextSpawnTick int `json:"nextSpawnTick"`
|
NextSpawnTick int `json:"nextSpawnTick"`
|
||||||
|
RngState uint32 `json:"rngState"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartResponse struct {
|
type StartResponse struct {
|
||||||
|
|||||||
308
websocket.go
Normal 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
|
||||||
|
}
|
||||||