From 732f507547387f972f5bd185a49a92ee3b6f3a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Tue, 25 Nov 2025 18:11:47 +0100 Subject: [PATCH] big refactor --- Dockerfile | 60 +++- config.go | 48 +++ handlers.go | 233 +++++++++++++ main.go | 674 +----------------------------------- rng.go | 27 ++ simulation.go | 230 +++++++++++++ static/game.js | 790 ++++++++++++++++--------------------------- static/index.html | 148 ++++---- static/js/config.js | 28 ++ static/js/input.js | 45 +++ static/js/logic.js | 59 ++++ static/js/main.js | 70 ++++ static/js/network.js | 92 +++++ static/js/render.js | 107 ++++++ static/js/state.js | 36 ++ static/style.css | 84 +++-- types.go | 83 +++++ 17 files changed, 1519 insertions(+), 1295 deletions(-) create mode 100644 config.go create mode 100644 handlers.go create mode 100644 rng.go create mode 100644 simulation.go create mode 100644 static/js/config.js create mode 100644 static/js/input.js create mode 100644 static/js/logic.js create mode 100644 static/js/main.js create mode 100644 static/js/network.js create mode 100644 static/js/render.js create mode 100644 static/js/state.js create mode 100644 types.go diff --git a/Dockerfile b/Dockerfile index 8358194..d3b4b41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,65 @@ -# --- STAGE 1: Builder (Kompilieren) --- -FROM golang:1.25-alpine AS builder +# ========================================== +# STAGE 1: JavaScript Minifier +# ========================================== +FROM node:alpine AS minifier +WORKDIR /minify -# Arbeitsverzeichnis im Container erstellen +# Tool installieren +RUN npm install -g uglify-js + +# Alle JS-Dateien kopieren +COPY static/js/ ./js/ + +# 1. Zusammenfügen (Reihenfolge ist wichtig!) +# 2. In IIFE (Function) wickeln für Kapselung (Sicherheit) +# 3. Minifizieren (Unlesbar machen) +RUN cat js/config.js js/state.js js/network.js js/input.js js/logic.js js/render.js js/main.js > temp.js \ + && echo "(function(){" > combined.js \ + && cat temp.js >> combined.js \ + && echo "})();" >> combined.js \ + && uglifyjs combined.js -o game.min.js -c -m toplevel + +# ========================================== +# STAGE 2: Go Builder +# ========================================== +FROM golang:1.23-alpine AS builder WORKDIR /app -# Abhängigkeiten kopieren und herunterladen (Caching-Effizienz) +# Abhängigkeiten laden COPY go.mod go.sum ./ RUN go mod download -# Den gesamten Rest des Codes kopieren +# Restlichen Code kopieren COPY . . -# Das Go-Programm kompilieren -# -o server: Nenne die Datei "server" -RUN go build -o server main.go +# Bauen (Der Punkt am Ende heißt: Alle Dateien im Ordner nutzen) +RUN go build -o server . -# --- STAGE 2: Runner (Ausführen) --- +# ========================================== +# STAGE 3: Final Runner (Production) +# ========================================== FROM alpine:latest - WORKDIR /root/ -# Wir brauchen evtl. Zertifikate für HTTPS (falls du später externe APIs nutzt) +# Notwendige Zertifikate RUN apk --no-cache add ca-certificates -# Kopiere das fertige Programm aus Stage 1 +# 1. Server Binary kopieren COPY --from=builder /app/server . -# WICHTIG: Kopiere die statischen Ordner (HTML, CSS, Bilder, Fonts, Admin) +# 2. Statische Dateien kopieren (Originale) COPY --from=builder /app/static ./static COPY --from=builder /app/secure ./secure -# Port freigeben -EXPOSE 8080 +# 3. Das MINIFIZIERTE JS kopieren (als einziges Script) +COPY --from=minifier /minify/game.min.js ./static/js/game.min.js -# Startbefehl +# 4. HTML ANPASSEN (Automatisch via sed) +# Schritt A: Lösche alle Zeilen, die ' static/index.html + +# Port und Start +EXPOSE 8080 CMD ["./server"] \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..8a87528 --- /dev/null +++ b/config.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/redis/go-redis/v9" +) + +const ( + Gravity = 0.6 + JumpPower = -12.0 + GroundY = 350.0 + PlayerHeight = 50.0 + PlayerYBase = GroundY - PlayerHeight + GameSpeed = 5.0 + GameWidth = 800.0 +) + +// Globale Variablen +var ( + ctx = context.Background() + rdb *redis.Client + defaultConfig GameConfig + adminUser string + adminPass string +) + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func initGameConfig() { + defaultConfig = GameConfig{ + Obstacles: []ObstacleDef{ + {ID: "desk", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"}, + {ID: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!", "Nachsitzen!"}}, + {ID: "trashcan", Width: 25, Height: 35, Color: "#555", Image: "trash.png"}, + {ID: "eraser", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0}, + }, + Backgrounds: []string{"background.jpg"}, + } + log.Println("✅ Config geladen") +} diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..f5b3a8c --- /dev/null +++ b/handlers.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/json" + "html" + "math/rand" + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +func handleConfig(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(defaultConfig) +} + +func handleStart(w http.ResponseWriter, r *http.Request) { + sessionID := uuid.New().String() + rawSeed := time.Now().UnixNano() + seed32 := uint32(rawSeed) + + emptyObs, _ := json.Marshal([]ActiveObstacle{}) + + err := rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ + "seed": seed32, + "rng_state": seed32, + "score": 0, + "is_dead": 0, + "pos_y": PlayerYBase, + "vel_y": 0.0, + "obstacles": string(emptyObs), + }).Err() + + if err != nil { + http.Error(w, "DB Error", 500) + return + } + rdb.Expire(ctx, "session:"+sessionID, 4000*time.Hour) + + json.NewEncoder(w).Encode(StartResponse{SessionID: sessionID, Seed: seed32}) +} + +func handleValidate(w http.ResponseWriter, r *http.Request) { + var req ValidateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", 400) + return + } + + key := "session:" + req.SessionID + vals, err := rdb.HGetAll(ctx, key).Result() + if err != nil || len(vals) == 0 { + http.Error(w, "Session invalid", 401) + return + } + + if vals["is_dead"] == "1" { + json.NewEncoder(w).Encode(ValidateResponse{Status: "dead", VerifiedScore: 0}) + return + } + + // ---> HIER RUFEN WIR JETZT DIE SIMULATION AUF <--- + isDead, score, obstacles := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals) + + status := "alive" + if isDead { + status = "dead" + rdb.HSet(ctx, key, "is_dead", 1) + } + rdb.Expire(ctx, key, 4000*time.Hour) + + json.NewEncoder(w).Encode(ValidateResponse{ + Status: status, + VerifiedScore: score, + ServerObs: obstacles, + }) +} + +func handleSubmitName(w http.ResponseWriter, r *http.Request) { + var req SubmitNameRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", 400) + return + } + + safeName := html.EscapeString(req.Name) + sessionKey := "session:" + req.SessionID + scoreVal, err := rdb.HGet(ctx, sessionKey, "score").Result() + if err != nil { + http.Error(w, "Session expired", 404) + return + } + scoreInt, _ := strconv.Atoi(scoreVal) + + claimCode := generateClaimCode() + timestamp := time.Now().Format("02.01.2006 15:04") + + rdb.HSet(ctx, sessionKey, map[string]interface{}{ + "name": safeName, + "claim_code": claimCode, + "created_at": timestamp, + }) + + rdb.ZAdd(ctx, "leaderboard:unverified", redis.Z{ + Score: float64(scoreInt), + Member: req.SessionID, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SubmitResponse{ClaimCode: claimCode}) +} + +func handleLeaderboard(w http.ResponseWriter, r *http.Request) { + mySessionID := r.URL.Query().Get("sessionId") + targetKey := "leaderboard:public" + + var entries []LeaderboardEntry + top3, _ := rdb.ZRevRangeWithScores(ctx, targetKey, 0, 2).Result() + + for i, z := range top3 { + rank := int64(i + 1) + sid := z.Member.(string) + name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() + if name == "" { + name = "Unbekannt" + } + entries = append(entries, LeaderboardEntry{ + Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), + }) + } + + if mySessionID != "" { + myRank, err := rdb.ZRevRank(ctx, targetKey, mySessionID).Result() + if err == nil && myRank > 2 { + start := myRank - 1 + stop := myRank + 1 + neighbors, _ := rdb.ZRevRangeWithScores(ctx, targetKey, start, stop).Result() + for i, z := range neighbors { + rank := start + int64(i) + 1 + sid := z.Member.(string) + name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() + if name == "" { + name = "Unbekannt" + } + entries = append(entries, LeaderboardEntry{ + Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), + }) + } + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func handleAdminPage(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./secure/admin.html") +} + +func handleAdminList(w http.ResponseWriter, r *http.Request) { + listType := r.URL.Query().Get("type") + redisKey := "leaderboard:unverified" + if listType == "public" { + redisKey = "leaderboard:public" + } + + vals, _ := rdb.ZRevRangeWithScores(ctx, redisKey, 0, -1).Result() + var adminList []AdminEntry + + for _, z := range vals { + sid := z.Member.(string) + info, _ := rdb.HGetAll(ctx, "session:"+sid).Result() + name := info["name"] + if name == "" { + name = "Unbekannt" + } + adminList = append(adminList, AdminEntry{ + SessionID: sid, Name: name, Score: int(z.Score), + Code: info["claim_code"], Time: info["created_at"], + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(adminList) +} + +func handleAdminAction(w http.ResponseWriter, r *http.Request) { + var req AdminActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", 400) + return + } + if req.Action == "approve" { + score, err := rdb.ZScore(ctx, "leaderboard:unverified", req.SessionID).Result() + if err == nil { + rdb.ZAdd(ctx, "leaderboard:public", redis.Z{Score: score, Member: req.SessionID}) + rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) + } + } else if req.Action == "delete" { + rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) + rdb.ZRem(ctx, "leaderboard:public", req.SessionID) + } + w.WriteHeader(http.StatusOK) +} + +func handleClaimDelete(w http.ResponseWriter, r *http.Request) { + var req ClaimDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", 400) + return + } + sessionKey := "session:" + req.SessionID + realCode, err := rdb.HGet(ctx, sessionKey, "claim_code").Result() + + if err != nil || realCode != req.ClaimCode { + http.Error(w, "Error", 403) + return + } + rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) + rdb.ZRem(ctx, "leaderboard:public", req.SessionID) + rdb.HDel(ctx, sessionKey, "name") + w.WriteHeader(http.StatusOK) +} + +func generateClaimCode() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} diff --git a/main.go b/main.go index c545695..5ecd291 100644 --- a/main.go +++ b/main.go @@ -1,192 +1,12 @@ package main import ( - "context" - "encoding/json" - "fmt" - "html" "log" - "math/rand" "net/http" - "os" - "strconv" - "time" - "github.com/google/uuid" "github.com/redis/go-redis/v9" ) -// ========================================== -// 1. KONSTANTEN -// ========================================== -const ( - Gravity = 0.6 - JumpPower = -12.0 - GroundY = 350.0 - PlayerHeight = 50.0 - PlayerYBase = GroundY - PlayerHeight // 300.0 - - GameSpeed = 5.0 - GameWidth = 800.0 -) - -// ========================================== -// 2. GLOBALE VARIABLEN -// ========================================== -var ( - ctx = context.Background() - rdb *redis.Client - defaultConfig GameConfig - adminUser string - adminPass string -) - -// ========================================== -// 3. STRUCTS -// ========================================== - -type ObstacleDef struct { - ID string `json:"id"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Color string `json:"color"` - Image string `json:"image"` - CanTalk bool `json:"canTalk"` - SpeechLines []string `json:"speechLines"` - YOffset float64 `json:"yOffset"` -} - -type GameConfig struct { - Obstacles []ObstacleDef `json:"obstacles"` - Backgrounds []string `json:"backgrounds"` -} - -type ActiveObstacle struct { - ID string `json:"id"` - X float64 `json:"x"` - Y float64 `json:"y"` - Width float64 `json:"w"` - Height float64 `json:"h"` -} - -type Input struct { - Tick int `json:"t"` - Act string `json:"act"` -} - -type ValidateRequest struct { - SessionID string `json:"sessionId"` - Inputs []Input `json:"inputs"` - TotalTicks int `json:"totalTicks"` -} - -type ValidateResponse struct { - Status string `json:"status"` - VerifiedScore int `json:"verifiedScore"` - ServerObs []ActiveObstacle `json:"serverObs"` -} - -type StartResponse struct { - SessionID string `json:"sessionId"` - Seed uint32 `json:"seed"` -} - -type SubmitNameRequest struct { - SessionID string `json:"sessionId"` - Name string `json:"name"` -} - -type SubmitResponse struct { - ClaimCode string `json:"claimCode"` -} - -type LeaderboardEntry struct { - Rank int64 `json:"rank"` - Name string `json:"name"` - Score int `json:"score"` - IsMe bool `json:"isMe"` -} - -type AdminActionRequest struct { - SessionID string `json:"sessionId"` - Action string `json:"action"` -} - -type AdminEntry struct { - SessionID string `json:"sessionId"` - Name string `json:"name"` - Score int `json:"score"` - Code string `json:"code"` - Time string `json:"time"` -} - -type ClaimDeleteRequest struct { - SessionID string `json:"sessionId"` - ClaimCode string `json:"claimCode"` -} - -// ========================================== -// 4. PSEUDO RNG -// ========================================== -type PseudoRNG struct { - State uint32 -} - -func NewRNG(seed int64) *PseudoRNG { - // DEBUG: RNG Init - // log.Printf("[RNG] Init mit Seed: %d", seed) - return &PseudoRNG{State: uint32(seed)} -} - -func (r *PseudoRNG) NextFloat() float64 { - calc := (uint64(r.State)*1664525 + 1013904223) % 4294967296 - r.State = uint32(calc) - return float64(r.State) / 4294967296.0 -} - -func (r *PseudoRNG) NextRange(min, max float64) float64 { - return min + (r.NextFloat() * (max - min)) -} - -func (r *PseudoRNG) PickDef(defs []ObstacleDef) *ObstacleDef { - if len(defs) == 0 { - return nil - } - idx := int(r.NextRange(0, float64(len(defs)))) - return &defs[idx] -} - -// ========================================== -// 5. HELPER -// ========================================== -func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - return fallback -} - -func parseOr(s string, def float64) float64 { - if s == "" { - return def - } - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return def - } - return v -} - -func generateClaimCode() string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, 8) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] - } - return string(b) -} - -// Middleware für Basic Auth func BasicAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() @@ -199,508 +19,32 @@ func BasicAuth(next http.HandlerFunc) http.HandlerFunc { } } -// Middleware für Request Logging (DAMIT DU SIEHST OB REQUESTS ANKOMMEN) -func LogRequest(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - log.Printf("➡ [REQ] %s %s von %s", r.Method, r.URL.Path, r.RemoteAddr) - next(w, r) - log.Printf("⬅ [RES] %s %s fertig in %v", r.Method, r.URL.Path, time.Since(start)) - } -} - -// ========================================== -// 6. MAIN -// ========================================== func main() { - // Config laden redisAddr := getEnv("REDIS_ADDR", "localhost:6379") adminUser = getEnv("ADMIN_USER", "lehrer") adminPass = getEnv("ADMIN_PASS", "geheim123") - log.Printf("🔧 CONFIG: Redis=%s | AdminUser=%s", redisAddr, adminUser) - - // Redis verbinden - log.Println("🔌 Verbinde zu Redis...") rdb = redis.NewClient(&redis.Options{Addr: redisAddr}) - - pong, err := rdb.Ping(ctx).Result() - if err != nil { - log.Fatalf("❌ REDIS ERROR: %v", err) + if _, err := rdb.Ping(ctx).Result(); err != nil { + log.Fatal("Redis:", err) } - log.Printf("✅ Redis verbunden: %s", pong) initGameConfig() - // File Server fs := http.FileServer(http.Dir("./static")) http.Handle("/", fs) - // API Routes (mit Logging Wrapper) - http.HandleFunc("/api/config", LogRequest(handleConfig)) - http.HandleFunc("/api/start", LogRequest(handleStart)) - http.HandleFunc("/api/validate", LogRequest(handleValidate)) // Hier passiert die Magie - http.HandleFunc("/api/submit-name", LogRequest(handleSubmitName)) - http.HandleFunc("/api/leaderboard", LogRequest(handleLeaderboard)) - http.HandleFunc("/api/claim/delete", LogRequest(handleClaimDelete)) + http.HandleFunc("/api/config", handleConfig) + http.HandleFunc("/api/start", handleStart) + http.HandleFunc("/api/validate", handleValidate) + http.HandleFunc("/api/submit-name", handleSubmitName) + http.HandleFunc("/api/leaderboard", handleLeaderboard) + http.HandleFunc("/api/claim/delete", handleClaimDelete) - // Admin Routes http.HandleFunc("/admin", BasicAuth(handleAdminPage)) http.HandleFunc("/api/admin/list", BasicAuth(handleAdminList)) http.HandleFunc("/api/admin/action", BasicAuth(handleAdminAction)) - log.Println("🦖 Server läuft auf Port 8080") + log.Println("🦖 Server läuft auf :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } - -func initGameConfig() { - defaultConfig = GameConfig{ - Obstacles: []ObstacleDef{ - {ID: "desk", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"}, - {ID: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!", "Nachsitzen!"}}, - {ID: "trashcan", Width: 25, Height: 35, Color: "#555", Image: "trash.png"}, - {ID: "eraser", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0}, - }, - Backgrounds: []string{"background.jpg"}, - } - log.Println("✅ Game Config initialisiert") -} - -// ========================================== -// 7. HANDLER (MIT DEBUG LOGS) -// ========================================== - -func handleConfig(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(defaultConfig) -} - -func handleStart(w http.ResponseWriter, r *http.Request) { - sessionID := uuid.New().String() - - // Seed erstellen (32-Bit sicher) - rawSeed := time.Now().UnixNano() - seed32 := uint32(rawSeed) - - // Initiale Hindernisse (leer) - emptyObs, _ := json.Marshal([]ActiveObstacle{}) - - log.Printf("🆕 START SESSION: %s | Seed: %d", sessionID, seed32) - - // In Redis speichern - err := rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ - "seed": seed32, - "rng_state": seed32, - "score": 0, - "is_dead": 0, - "pos_y": PlayerYBase, // WICHTIG: Start auf dem Boden (300.0) - "vel_y": 0.0, - "obstacles": string(emptyObs), - }).Err() - - if err != nil { - log.Printf("❌ REDIS WRITE ERROR bei Start: %v", err) - http.Error(w, "DB Error", 500) - return - } - - // Expire setzen - rdb.Expire(ctx, "session:"+sessionID, 8760*time.Hour) - - json.NewEncoder(w).Encode(StartResponse{SessionID: sessionID, Seed: seed32}) -} - -func handleValidate(w http.ResponseWriter, r *http.Request) { - var req ValidateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Printf("⚠️ Validate Decode Error: %v", err) - http.Error(w, "Bad Request", 400) - return - } - - key := "session:" + req.SessionID - - // Alle Daten holen - vals, err := rdb.HGetAll(ctx, key).Result() - if err != nil || len(vals) == 0 { - log.Printf("⚠️ Session nicht gefunden oder leer: %s", req.SessionID) - http.Error(w, "Session invalid", 401) - return - } - - // Check ob schon tot - if vals["is_dead"] == "1" { - // log.Printf("💀 Validierung abgelehnt: Spieler %s ist bereits tot.", req.SessionID) - json.NewEncoder(w).Encode(ValidateResponse{Status: "dead", VerifiedScore: 0}) - return - } - - // State parsen - posY := parseOr(vals["pos_y"], PlayerYBase) - velY := parseOr(vals["vel_y"], 0.0) - score := int(parseOr(vals["score"], 0)) - rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64) - - // RNG initialisieren - rng := NewRNG(rngStateVal) - - // Hindernisse laden - var obstacles []ActiveObstacle - if val, ok := vals["obstacles"]; ok && val != "" { - json.Unmarshal([]byte(val), &obstacles) - } else { - obstacles = []ActiveObstacle{} - } - - // log.Printf("🎮 SIMULATION START (%s) | Ticks: %d | PosY: %.2f | Obstacles: %d", req.SessionID, req.TotalTicks, posY, len(obstacles)) - - playerDead := false - - // --- SIMULATION LOOP --- - for i := 0; i < req.TotalTicks; i++ { - - // A. INPUT - didJump := false - isCrouching := false - for _, inp := range req.Inputs { - if inp.Tick == i { - if inp.Act == "JUMP" { - didJump = true - } - if inp.Act == "DUCK" { - isCrouching = true - } - } - } - - // B. PHYSIK - // Toleranz 1.0px - isGrounded := posY >= PlayerYBase-1.0 - - currentHeight := PlayerHeight - if isCrouching { - currentHeight = PlayerHeight / 2 - if !isGrounded { - velY += 2.0 - } // Fast fall - } - - if didJump && isGrounded && !isCrouching { - velY = JumpPower - } - - velY += Gravity - posY += velY - - if posY > PlayerYBase { - posY = PlayerYBase - velY = 0 - } - - // Hitbox Y berechnen - hitboxY := posY - if isCrouching { - hitboxY = posY + (PlayerHeight - currentHeight) - } - - // C. OBSTACLES & KOLLISION - nextObstacles := []ActiveObstacle{} - rightmostX := 0.0 - - for _, obs := range obstacles { - obs.X -= GameSpeed - - // Ist Hindernis schon vorbei? (Hinter Spieler) - if obs.X+obs.Width < 50.0 { - // log.Printf("🗑️ Obstacle %s passed safely", obs.ID) - continue // Nicht in nextObstacles aufnehmen -> wird gelöscht - } - - // Kollisions-Logik - paddingX := 5.0 - paddingY_Top := 5.0 - paddingY_Bottom := 5.0 - - pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX - pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-paddingY_Bottom - - oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX - oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-paddingY_Bottom - - if pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom { - log.Printf("💥 KOLLISION! Session: %s | Obs: %s at %.2f | PlayerY: %.2f | Crouch: %v", req.SessionID, obs.ID, obs.X, posY, isCrouching) - playerDead = true - } - - // Behalten wenn noch relevant (sichtbar oder kurz davor) - // Wir behalten es etwas länger, damit Client Synchronisation nicht springt - if obs.X+obs.Width > -100 { - nextObstacles = append(nextObstacles, obs) - if obs.X+obs.Width > rightmostX { - rightmostX = obs.X + obs.Width - } - } - } - obstacles = nextObstacles - - // D. SPAWNING - if rightmostX < GameWidth-10.0 { - rawGap := 400.0 + rng.NextRange(0, 500) - gap := float64(int(rawGap)) - spawnX := rightmostX + gap - if spawnX < GameWidth { - spawnX = GameWidth - } - - var possibleDefs []ObstacleDef - for _, d := range defaultConfig.Obstacles { - if d.ID == "eraser" { - if score >= 500 { - possibleDefs = append(possibleDefs, d) - } - } else { - possibleDefs = append(possibleDefs, d) - } - } - - def := rng.PickDef(possibleDefs) - - if def != nil && def.CanTalk { - if rng.NextFloat() > 0.7 { - rng.NextFloat() - } - } - - if def != nil { - spawnY := GroundY - def.Height - def.YOffset - // log.Printf("✨ SPAWN: %s at %.2f (Y: %.2f)", def.ID, spawnX, spawnY) - - obstacles = append(obstacles, ActiveObstacle{ - ID: def.ID, - X: spawnX, - Y: spawnY, - Width: def.Width, - Height: def.Height, - }) - } - } - - if !playerDead { - score++ - } else { - // Wenn tot, brechen wir die Loop ab, um Rechenzeit zu sparen - // und senden den Dead-Status zurück - break - } - } - - // --- SPEICHERN --- - - status := "alive" - if playerDead { - status = "dead" - rdb.HSet(ctx, key, "is_dead", 1) - } - - obsJson, _ := json.Marshal(obstacles) - - err = rdb.HSet(ctx, key, map[string]interface{}{ - "score": score, - "pos_y": fmt.Sprintf("%f", posY), - "vel_y": fmt.Sprintf("%f", velY), - "rng_state": rng.State, - "obstacles": string(obsJson), - }).Err() - - if err != nil { - log.Printf("❌ REDIS SAVE ERROR: %v", err) - } - - rdb.Expire(ctx, key, 8760*time.Hour) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ValidateResponse{ - Status: status, - VerifiedScore: score, - ServerObs: obstacles, - }) -} - -func handleSubmitName(w http.ResponseWriter, r *http.Request) { - var req SubmitNameRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", 400) - return - } - - log.Printf("📝 SUBMIT NAME: %s für Session %s", req.Name, req.SessionID) - - safeName := html.EscapeString(req.Name) - sessionKey := "session:" + req.SessionID - - scoreVal, err := rdb.HGet(ctx, sessionKey, "score").Result() - if err != nil { - log.Printf("⚠️ Submit fehlgeschlagen: Session nicht gefunden %s", req.SessionID) - http.Error(w, "Session expired", 404) - return - } - scoreInt, _ := strconv.Atoi(scoreVal) - - claimCode := generateClaimCode() - timestamp := time.Now().Format("02.01.2006 15:04") - - rdb.HSet(ctx, sessionKey, map[string]interface{}{ - "name": safeName, - "claim_code": claimCode, - "created_at": timestamp, - }) - - rdb.ZAdd(ctx, "leaderboard:unverified", redis.Z{ - Score: float64(scoreInt), - Member: req.SessionID, - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(SubmitResponse{ClaimCode: claimCode}) -} - -func handleLeaderboard(w http.ResponseWriter, r *http.Request) { - mySessionID := r.URL.Query().Get("sessionId") - targetKey := "leaderboard:public" - - var entries []LeaderboardEntry - - top3, _ := rdb.ZRevRangeWithScores(ctx, targetKey, 0, 2).Result() - - for i, z := range top3 { - rank := int64(i + 1) - sid := z.Member.(string) - name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() - if name == "" { - name = "Unbekannt" - } - - entries = append(entries, LeaderboardEntry{ - Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), - }) - } - - if mySessionID != "" { - myRank, err := rdb.ZRevRank(ctx, targetKey, mySessionID).Result() - if err == nil { - if myRank > 2 { - start := myRank - 1 - stop := myRank + 1 - neighbors, _ := rdb.ZRevRangeWithScores(ctx, targetKey, start, stop).Result() - - for i, z := range neighbors { - rank := start + int64(i) + 1 - sid := z.Member.(string) - name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() - if name == "" { - name = "Unbekannt" - } - - entries = append(entries, LeaderboardEntry{ - Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), - }) - } - } - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(entries) -} - -func handleAdminPage(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./secure/admin.html") -} - -func handleAdminList(w http.ResponseWriter, r *http.Request) { - listType := r.URL.Query().Get("type") - redisKey := "leaderboard:unverified" - if listType == "public" { - redisKey = "leaderboard:public" - } - - vals, _ := rdb.ZRevRangeWithScores(ctx, redisKey, 0, -1).Result() - var adminList []AdminEntry - - for _, z := range vals { - sid := z.Member.(string) - info, _ := rdb.HGetAll(ctx, "session:"+sid).Result() - - name := info["name"] - if name == "" { - name = "Unbekannt" - } - - adminList = append(adminList, AdminEntry{ - SessionID: sid, - Name: name, - Score: int(z.Score), - Code: info["claim_code"], - Time: info["created_at"], - }) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(adminList) -} - -func handleAdminAction(w http.ResponseWriter, r *http.Request) { - var req AdminActionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", 400) - return - } - - log.Printf("👮 ADMIN ACTION: %s on %s", req.Action, req.SessionID) - - if req.Action == "approve" { - score, err := rdb.ZScore(ctx, "leaderboard:unverified", req.SessionID).Result() - if err != nil { - http.Error(w, "Entry not found", 404) - return - } - rdb.ZAdd(ctx, "leaderboard:public", redis.Z{Score: score, Member: req.SessionID}) - rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) - - } else if req.Action == "delete" { - rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) - rdb.ZRem(ctx, "leaderboard:public", req.SessionID) - } - - w.WriteHeader(http.StatusOK) -} - -func handleClaimDelete(w http.ResponseWriter, r *http.Request) { - var req ClaimDeleteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", 400) - return - } - - sessionKey := "session:" + req.SessionID - realCode, err := rdb.HGet(ctx, sessionKey, "claim_code").Result() - - if err != nil || realCode == "" { - log.Printf("⚠️ Claim Delete Failed: Session/Code missing %s", req.SessionID) - http.Error(w, "Not found", 404) - return - } - - if realCode != req.ClaimCode { - log.Printf("⛔ Claim Delete Denied: Wrong Code %s vs %s", req.ClaimCode, realCode) - http.Error(w, "Wrong Code", 403) - return - } - - log.Printf("🗑️ USER DELETE: Session %s deleted via code", req.SessionID) - rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) - rdb.ZRem(ctx, "leaderboard:public", req.SessionID) - rdb.HDel(ctx, sessionKey, "name") - - w.WriteHeader(http.StatusOK) -} diff --git a/rng.go b/rng.go new file mode 100644 index 0000000..8bf621a --- /dev/null +++ b/rng.go @@ -0,0 +1,27 @@ +package main + +type PseudoRNG struct { + State uint32 +} + +func NewRNG(seed int64) *PseudoRNG { + return &PseudoRNG{State: uint32(seed)} +} + +func (r *PseudoRNG) NextFloat() float64 { + calc := (uint64(r.State)*1664525 + 1013904223) % 4294967296 + r.State = uint32(calc) + return float64(r.State) / 4294967296.0 +} + +func (r *PseudoRNG) NextRange(min, max float64) float64 { + return min + (r.NextFloat() * (max - min)) +} + +func (r *PseudoRNG) PickDef(defs []ObstacleDef) *ObstacleDef { + if len(defs) == 0 { + return nil + } + idx := int(r.NextRange(0, float64(len(defs)))) + return &defs[idx] +} diff --git a/simulation.go b/simulation.go new file mode 100644 index 0000000..99fa9dc --- /dev/null +++ b/simulation.go @@ -0,0 +1,230 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "math" + "strconv" +) + +// Führt die Physik-Simulation durch und prüft auf Cheats +func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle) { + + // State parsen + posY := parseOr(vals["pos_y"], PlayerYBase) + velY := parseOr(vals["vel_y"], 0.0) + score := int(parseOr(vals["score"], 0)) + rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64) + + // Anti-Cheat State laden + lastJumpDist := parseOr(vals["ac_last_dist"], 0.0) + suspicionScore := int(parseOr(vals["ac_suspicion"], 0)) + + rng := NewRNG(rngStateVal) + + var obstacles []ActiveObstacle + if val, ok := vals["obstacles"]; ok && val != "" { + json.Unmarshal([]byte(val), &obstacles) + } else { + obstacles = []ActiveObstacle{} + } + + // --- ANTI-CHEAT STUFE 2: SPAM SCHUTZ --- + jumpCount := 0 + for _, inp := range inputs { + if inp.Act == "JUMP" { + jumpCount++ + } + } + if jumpCount > 8 { // Wer mehr als 8x pro Sekunde springt, ist ein Bot + log.Printf("🤖 BOT ALARM (Spam): %s sprang %d mal!", sessionID, jumpCount) + return true, score, obstacles // Player Dead + } + + playerDead := false + + // --- SIMULATION LOOP --- + for i := 0; i < totalTicks; i++ { + + // A. INPUT + didJump := false + isCrouching := false + for _, inp := range inputs { + if inp.Tick == i { + if inp.Act == "JUMP" { + didJump = true + } + if inp.Act == "DUCK" { + isCrouching = true + } + } + } + + // Physik Check + isGrounded := posY >= PlayerYBase-1.0 + + if didJump && isGrounded && !isCrouching { + velY = JumpPower + + // --- ANTI-CHEAT STUFE 3: HEURISTIK (Perfektes Springen) --- + // Wir messen den Abstand zum nächsten Hindernis beim Absprung + nextObsDist := -1.0 + for _, o := range obstacles { + if o.X > 50.0 { // Erstes Hindernis vor uns + nextObsDist = o.X - 50.0 + break + } + } + + if nextObsDist > 0 { + // Bot-Check: Springt er immer exakt bei "75.5" Pixel Abstand? + diff := math.Abs(nextObsDist - lastJumpDist) + if diff < 1.0 { + // Abstand ist fast identisch zum letzten Sprung -> Verdächtig + suspicionScore++ + } else { + // Menschliche Varianz -> Reset (oder verringern) + if suspicionScore > 0 { + suspicionScore-- + } + } + lastJumpDist = nextObsDist + } + } + + // ... (Restliche Physik wie gehabt) ... + currentHeight := PlayerHeight + if isCrouching { + currentHeight = PlayerHeight / 2 + if !isGrounded { + velY += 2.0 + } + } + + velY += Gravity + posY += velY + + if posY > PlayerYBase { + posY = PlayerYBase + velY = 0 + } + + hitboxY := posY + if isCrouching { + hitboxY = posY + (PlayerHeight - currentHeight) + } + + // B. OBSTACLES + nextObstacles := []ActiveObstacle{} + rightmostX := 0.0 + + for _, obs := range obstacles { + obs.X -= GameSpeed + + if obs.X+obs.Width < 50.0 { + continue + } + + // Hitbox + paddingX := 10.0 + paddingY_Top := 25.0 + paddingY_Bottom := 5.0 + + pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX + pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-paddingY_Bottom + + oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX + oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-paddingY_Bottom + + if pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom { + playerDead = true + } + + if obs.X+obs.Width > -100 { + nextObstacles = append(nextObstacles, obs) + if obs.X+obs.Width > rightmostX { + rightmostX = obs.X + obs.Width + } + } + } + obstacles = nextObstacles + + // C. SPAWNING + if rightmostX < GameWidth-10.0 { + rawGap := 400.0 + rng.NextRange(0, 500) + gap := float64(int(rawGap)) + spawnX := rightmostX + gap + if spawnX < GameWidth { + spawnX = GameWidth + } + + var possibleDefs []ObstacleDef + for _, d := range defaultConfig.Obstacles { + if d.ID == "eraser" { + if score >= 500 { + possibleDefs = append(possibleDefs, d) + } + } else { + possibleDefs = append(possibleDefs, d) + } + } + + def := rng.PickDef(possibleDefs) + if def != nil && def.CanTalk { + if rng.NextFloat() > 0.7 { + rng.NextFloat() + } + } + + if def != nil { + spawnY := GroundY - def.Height - def.YOffset + obstacles = append(obstacles, ActiveObstacle{ + ID: def.ID, + X: spawnX, + Y: spawnY, + Width: def.Width, + Height: def.Height, + }) + } + } + + if !playerDead { + score++ + } else { + break + } + } + + // Ban Hammer für Bots + if suspicionScore > 8 { + log.Printf("🤖 BOT ALARM (Heuristik): %s springt zu perfekt!", sessionID) + playerDead = true + } + + // State speichern + obsJson, _ := json.Marshal(obstacles) + rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ + "score": score, + "pos_y": fmt.Sprintf("%f", posY), + "vel_y": fmt.Sprintf("%f", velY), + "rng_state": rng.State, + "obstacles": string(obsJson), + // Anti-Cheat Daten mitspeichern + "ac_last_dist": fmt.Sprintf("%f", lastJumpDist), + "ac_suspicion": suspicionScore, + }) + + return playerDead, score, obstacles +} + +func parseOr(s string, def float64) float64 { + if s == "" { + return def + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return def + } + return v +} diff --git a/static/game.js b/static/game.js index 43b0a54..b8ef63c 100644 --- a/static/game.js +++ b/static/game.js @@ -1,538 +1,328 @@ -const canvas = document.getElementById('gameCanvas'); -const ctx = canvas.getContext('2d'); -const container = document.getElementById('game-container'); +(function() { -const startScreen = document.getElementById('startScreen'); -const startBtn = document.getElementById('startBtn'); -const loadingText = document.getElementById('loadingText'); -const gameOverScreen = document.getElementById('gameOverScreen'); + const canvas = document.getElementById('gameCanvas'); + const ctx = canvas.getContext('2d'); + const container = document.getElementById('game-container'); -class PseudoRNG { - constructor(seed) { - this.state = BigInt(seed); - } + const startScreen = document.getElementById('startScreen'); + const startBtn = document.getElementById('startBtn'); + const loadingText = document.getElementById('loadingText'); + const gameOverScreen = document.getElementById('gameOverScreen'); - 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]; - } -} - -const GAME_WIDTH = 800; -const GAME_HEIGHT = 400; -canvas.width = GAME_WIDTH; -canvas.height = GAME_HEIGHT; - -const GRAVITY = 0.6; -const JUMP_POWER = -12; -const GROUND_Y = 350; -const GAME_SPEED = 5; -const CHUNK_SIZE = 60; - -let gameConfig = null; -let isLoaded = false; -let isGameRunning = false; -let isGameOver = false; -let sessionID = null; - -let rng = null; -let score = 0; -let currentTick = 0; -let lastSentTick = 0; -let inputLog = []; -let isCrouching = false; - -let sprites = {}; -let playerSprite = new Image(); -let bgSprite = new Image(); - -let player = { - x: 50, y: 300, w: 30, h: 50, color: "red", - vy: 0, grounded: false -}; - -let obstacles = []; - -function resize() { - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; - const targetRatio = GAME_WIDTH / GAME_HEIGHT; - - let finalWidth, finalHeight; - if (windowWidth / windowHeight < targetRatio) { - finalWidth = windowWidth; - finalHeight = windowWidth / targetRatio; - } else { - finalHeight = windowHeight; - finalWidth = finalHeight * targetRatio; - } - - canvas.style.width = `${finalWidth}px`; - canvas.style.height = `${finalHeight}px`; - if(container) { - container.style.width = `${finalWidth}px`; - container.style.height = `${finalHeight}px`; - } -} -window.addEventListener('resize', resize); -resize(); - -async function loadAssets() { - playerSprite.src = "assets/player.gif"; - - if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) { - const bgName = gameConfig.backgrounds[0]; - if (!bgName.startsWith("#")) { - bgSprite.src = "assets/" + bgName; + class PseudoRNG { + constructor(seed) { + this.state = BigInt(seed); + } + nextFloat() { + const a = 1664525n; const c = 1013904223n; const m = 4294967296n; + this.state = (this.state * a + c) % m; + return Number(this.state) / Number(m); + } + nextRange(min, max) { + return min + (this.nextFloat() * (max - min)); + } + pick(array) { + if (!array || array.length === 0) return null; + const idx = Math.floor(this.nextRange(0, array.length)); + return array[idx]; } } - const promises = 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(); }; +// Config + const GAME_WIDTH = 800; const GAME_HEIGHT = 400; + canvas.width = GAME_WIDTH; canvas.height = GAME_HEIGHT; + const GRAVITY = 0.6; const JUMP_POWER = -12; const GROUND_Y = 350; + const GAME_SPEED = 5; const CHUNK_SIZE = 60; + +// State (JETZT PRIVATE VARIABLEN!) + let gameConfig = null; + let isLoaded = false; + let isGameRunning = false; + let isGameOver = false; + let sessionID = null; + let rng = null; + let score = 0; + let currentTick = 0; + let lastSentTick = 0; + let inputLog = []; + let isCrouching = false; + let sprites = {}; + let playerSprite = new Image(); + let bgSprite = new Image(); + let player = { x: 50, y: 300, w: 30, h: 50, color: "red", vy: 0, grounded: false }; + let obstacles = []; + let serverObstacles = []; + +// --- Funktionen --- + + function resize() { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const targetRatio = GAME_WIDTH / GAME_HEIGHT; + let finalWidth, finalHeight; + if (windowWidth / windowHeight < targetRatio) { + finalWidth = windowWidth; finalHeight = windowWidth / targetRatio; + } else { + finalHeight = windowHeight; finalWidth = finalHeight * targetRatio; + } + canvas.style.width = `${finalWidth}px`; + canvas.style.height = `${finalHeight}px`; + if(container) { container.style.width = `${finalWidth}px`; container.style.height = `${finalHeight}px`; } + } + window.addEventListener('resize', resize); resize(); + + async function loadAssets() { + playerSprite.src = "assets/player.png"; + if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) { + const bgName = gameConfig.backgrounds[0]; + if (!bgName.startsWith("#")) bgSprite.src = "assets/" + bgName; + } + const promises = 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(); }; + }); }); - }); - - if (bgSprite.src) { - promises.push(new Promise(r => { - bgSprite.onload = r; - bgSprite.onerror = r; - })); - } - - await Promise.all(promises); -} - -window.startGameClick = async function() { - if (!isLoaded) return; - startScreen.style.display = 'none'; - - try { - const sRes = await fetch('/api/start', {method:'POST'}); - const sData = await sRes.json(); - - sessionID = sData.sessionId; - rng = new PseudoRNG(sData.seed); - isGameRunning = true; - } catch(e) { - location.reload(); - } -}; - -function handleInput(action, active) { - if (isGameOver) { if(active) location.reload(); return; } - - const relativeTick = currentTick - lastSentTick; - - if (action === "JUMP" && active) { - if (player.grounded && !isCrouching) { - player.vy = JUMP_POWER; - player.grounded = false; - inputLog.push({ t: relativeTick, act: "JUMP" }); + if (bgSprite.src) { + promises.push(new Promise(r => { bgSprite.onload = r; bgSprite.onerror = r; })); } + await Promise.all(promises); } - if (action === "DUCK") { - isCrouching = active; - } -} - -window.addEventListener('keydown', (e) => { - if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true); - if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true); -}); - -window.addEventListener('keyup', (e) => { - if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false); -}); - -window.addEventListener('mousedown', (e) => { - if (e.target === canvas && e.button === 0) { - handleInput("JUMP", true); - } -}); - -let touchStartY = 0; -window.addEventListener('touchstart', (e) => { - if(e.target === canvas) { - e.preventDefault(); - touchStartY = e.touches[0].clientY; - } -}, { passive: false }); - -window.addEventListener('touchend', (e) => { - if(e.target === canvas) { - e.preventDefault(); - const touchEndY = e.changedTouches[0].clientY; - const diff = touchEndY - touchStartY; - - if (diff < -30) { - handleInput("JUMP", true); - } else if (diff > 30) { - handleInput("DUCK", true); - setTimeout(() => handleInput("DUCK", false), 800); - } else if (Math.abs(diff) < 10) { - handleInput("JUMP", true); - } - } -}); - -async function sendChunk() { - const ticksToSend = currentTick - lastSentTick; - if (ticksToSend <= 0) return; - - const payload = { - sessionId: sessionID, - inputs: [...inputLog], - totalTicks: ticksToSend +// Global verfügbar machen für HTML Button + window.startGameClick = async function() { + if (!isLoaded) return; + startScreen.style.display = 'none'; + try { + const sRes = await fetch('/api/start', {method:'POST'}); + const sData = await sRes.json(); + sessionID = sData.sessionId; + rng = new PseudoRNG(sData.seed); + isGameRunning = true; + } catch(e) { location.reload(); } }; - inputLog = []; - lastSentTick = currentTick; - - try { - const res = await fetch('/api/validate', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - - const data = await res.json(); - - if (data.status === "dead") { - gameOver("Vom Server gestoppt"); - } else { - const sScore = data.verifiedScore; - if (Math.abs(score - sScore) > 200) { - score = sScore; - } - } - } catch (e) { - console.error(e); - } -} - -function updateGameLogic() { - if (isCrouching) { + function handleInput(action, active) { + if (isGameOver) { if(active) location.reload(); return; } const relativeTick = currentTick - lastSentTick; - inputLog.push({ t: relativeTick, act: "DUCK" }); - } - - const originalHeight = 50; - const crouchHeight = 25; - player.h = isCrouching ? crouchHeight : originalHeight; - - let drawY = player.y; - if (isCrouching) { - drawY = player.y + (originalHeight - crouchHeight); - } - - player.vy += GRAVITY; - if (isCrouching && !player.grounded) player.vy += 2.0; - - player.y += player.vy; - - if (player.y + originalHeight >= GROUND_Y) { - player.y = GROUND_Y - originalHeight; - player.vy = 0; - player.grounded = true; - } else { - player.grounded = false; - } - - let nextObstacles = []; - let rightmostX = 0; - - for (let obs of obstacles) { - obs.x -= GAME_SPEED; - - const playerHitbox = { - x: player.x, - y: drawY, - w: player.w, - h: player.h - }; - - if (checkCollision(playerHitbox, obs)) { - player.color = "darkred"; - if (!isGameOver) { - sendChunk(); - gameOver("Kollision (Client)"); - } - } - - if (obs.x + obs.def.width > -100) { - nextObstacles.push(obs); - if (obs.x + obs.def.width > rightmostX) { - rightmostX = obs.x + obs.def.width; + if (action === "JUMP" && active) { + if (player.grounded && !isCrouching) { + player.vy = JUMP_POWER; player.grounded = false; + inputLog.push({ t: relativeTick, act: "JUMP" }); } } + if (action === "DUCK") { isCrouching = active; } } - obstacles = nextObstacles; - if (rightmostX < GAME_WIDTH - 10 && gameConfig && gameConfig.obstacles) { - const gap = Math.floor(400 + rng.nextRange(0, 500)); - let spawnX = rightmostX + gap; - if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + window.addEventListener('keydown', (e) => { + if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true); + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true); + }); + window.addEventListener('keyup', (e) => { + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false); + }); + window.addEventListener('mousedown', (e) => { + if (e.target === canvas && e.button === 0) handleInput("JUMP", true); + }); + let touchStartY = 0; + window.addEventListener('touchstart', (e) => { + if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; } + }, { passive: false }); + window.addEventListener('touchend', (e) => { + if(e.target === canvas) { + e.preventDefault(); + const diff = e.changedTouches[0].clientY - touchStartY; + if (diff < -30) handleInput("JUMP", true); + else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); } + else if (Math.abs(diff) < 10) handleInput("JUMP", true); + } + }); - let possibleObs = []; - gameConfig.obstacles.forEach(def => { - if (def.id === "eraser") { - if (score >= 500) possibleObs.push(def); - } else { - possibleObs.push(def); + async function sendChunk() { + const ticksToSend = currentTick - lastSentTick; + if (ticksToSend <= 0) return; + const payload = { sessionId: sessionID, inputs: [...inputLog], totalTicks: ticksToSend }; + inputLog = []; lastSentTick = currentTick; + try { + const res = await fetch('/api/validate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); + const data = await res.json(); + if (data.serverObs) serverObstacles = data.serverObs; + if (data.status === "dead") gameOver("Vom Server gestoppt"); + else { + const sScore = data.verifiedScore; + if (Math.abs(score - sScore) > 200) score = sScore; } - }); + } catch (e) {} + } - const def = rng.pick(possibleObs); + function updateGameLogic() { + if (isCrouching) { + const relativeTick = currentTick - lastSentTick; + inputLog.push({ t: relativeTick, act: "DUCK" }); + } + const originalHeight = 50; const crouchHeight = 25; + player.h = isCrouching ? crouchHeight : originalHeight; + let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y; + player.vy += GRAVITY; + if (isCrouching && !player.grounded) player.vy += 2.0; + player.y += player.vy; + if (player.y + originalHeight >= GROUND_Y) { + player.y = GROUND_Y - originalHeight; player.vy = 0; player.grounded = true; + } else { player.grounded = false; } - let speech = null; - if (def && def.canTalk) { - if (rng.nextFloat() > 0.7) { - speech = rng.pick(def.speechLines); + let nextObstacles = []; let rightmostX = 0; + for (let obs of obstacles) { + obs.x -= GAME_SPEED; + const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h }; + if (checkCollision(playerHitbox, obs)) { + player.color = "darkred"; + if (!isGameOver) { sendChunk(); gameOver("Kollision"); } + } + if (obs.x + obs.def.width > -100) { + nextObstacles.push(obs); + if (obs.x + obs.def.width > rightmostX) rightmostX = obs.x + obs.def.width; } } + obstacles = nextObstacles; - if (def) { - const yOffset = def.yOffset || 0; - obstacles.push({ - x: spawnX, - y: GROUND_Y - def.height - yOffset, - def: def, - speech: speech + if (rightmostX < GAME_WIDTH - 10 && gameConfig) { + const gap = Math.floor(400 + rng.nextRange(0, 500)); + let spawnX = rightmostX + gap; if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + let possibleObs = []; + gameConfig.obstacles.forEach(def => { + if (def.id === "eraser") { if (score >= 500) possibleObs.push(def); } else possibleObs.push(def); }); + const def = rng.pick(possibleObs); + let speech = null; + if (def && def.canTalk) { if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); } + if (def) { + const yOffset = def.yOffset || 0; + obstacles.push({ x: spawnX, y: GROUND_Y - def.height - yOffset, def: def, speech: speech }); + } } } -} -function checkCollision(p, obs) { - const paddingX = 5; - const paddingY_Top = 5; - const paddingY_Bottom = 5; - - return ( - p.x + p.w - paddingX > obs.x + paddingX && - p.x + paddingX < obs.x + obs.def.width - paddingX && - p.y + p.h - paddingY_Bottom > obs.y + paddingY_Top && - p.y + paddingY_Top < obs.y + obs.def.height - paddingY_Bottom - ); -} - -window.submitScore = async function() { - const nameInput = document.getElementById('playerNameInput'); - const name = nameInput.value; - const btn = document.getElementById('submitBtn'); - - if (!name) return alert("Namen eingeben!"); - btn.disabled = true; - - try { - const res = await fetch('/api/submit-name', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ sessionId: sessionID, name: name }) - }); - - const data = await res.json(); - const claimCode = data.claimCode; - - let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]'); - - myClaims.push({ - name: name, - score: Math.floor(score / 10), - code: claimCode, - date: new Date().toLocaleString('de-DE'), - sessionId: sessionID - }); - - localStorage.setItem('escape_claims', JSON.stringify(myClaims)); - - alert(`Gespeichert!\nDein Beweis-Code: ${claimCode}\n(Findest du unter "Meine Codes")`); - - document.getElementById('inputSection').style.display = 'none'; - loadLeaderboard(); - - } catch (e) { - console.error(e); - alert("Fehler beim Speichern!"); - btn.disabled = false; - } -}; - -async function loadLeaderboard() { - const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`); - const entries = await res.json(); - let html = "

BESTENLISTE

"; - entries.forEach(e => { - const color = e.isMe ? "yellow" : "white"; - html += `
- #${e.rank} ${e.name}${Math.floor(e.score/10)} -
`; - if(e.rank===3 && entries.length>3) html+="
...
"; - }); - document.getElementById('leaderboard').innerHTML = html; -} - -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); + function checkCollision(p, obs) { + const paddingX = 10; const paddingY_Top = 25; const paddingY_Bottom = 5; + return (p.x + p.w - paddingX > obs.x + paddingX && p.x + paddingX < obs.x + obs.def.width - paddingX && + p.y + p.h - paddingY_Bottom > obs.y + paddingY_Top && p.y + paddingY_Top < obs.y + obs.def.height - paddingY_Bottom); } - gameOverScreen.style.display = 'flex'; - document.getElementById('finalScore').innerText = finalScoreVal; + window.submitScore = async function() { + const nameInput = document.getElementById('playerNameInput'); + const name = nameInput.value; + const btn = document.getElementById('submitBtn'); + if (!name) return alert("Namen eingeben!"); + btn.disabled = true; + try { + const res = await fetch('/api/submit-name', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ sessionId: sessionID, name: name }) + }); + const data = await res.json(); - loadLeaderboard(); - drawGame(); -} + 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)); -function drawGame() { - ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + document.getElementById('inputSection').style.display = 'none'; + loadLeaderboard(); + } catch (e) {} + }; - if (bgSprite.complete && bgSprite.naturalHeight !== 0) { - ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT); - } else { - const bgColor = (gameConfig && gameConfig.backgrounds) ? gameConfig.backgrounds[0] : "#eee"; - if (bgColor.startsWith("#")) ctx.fillStyle = bgColor; - else ctx.fillStyle = "#f0f0f0"; - ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); - } - - ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; - ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50); - - obstacles.forEach(obs => { - const img = sprites[obs.def.id]; - if (img) { - ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height); - } else { - ctx.fillStyle = obs.def.color; - ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height); - } - if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech); - }); - - const drawY = isCrouching ? player.y + 25 : player.y; - const drawH = isCrouching ? 25 : 50; - - if (playerSprite.complete && playerSprite.naturalHeight !== 0) { - ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH); - } else { - ctx.fillStyle = player.color; - ctx.fillRect(player.x, drawY, player.w, drawH); - } - - if (isGameOver) { - ctx.fillStyle = "rgba(0,0,0,0.7)"; - ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); - } -} - -function drawSpeechBubble(x, y, text) { - const bX = x-20; const bY = y-40; const bW = 120; const bH = 30; - ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH); - ctx.strokeRect(bX,bY,bW,bH); - ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center"; - ctx.fillText(text, bX+bW/2, bY+20); -} - -function gameLoop() { - if (!isLoaded) return; - - if (isGameRunning && !isGameOver) { - updateGameLogic(); - currentTick++; - score++; - - const scoreEl = document.getElementById('score'); - if (scoreEl) scoreEl.innerText = Math.floor(score / 10); - - if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk(); - } - - drawGame(); - requestAnimationFrame(gameLoop); -} - -async function initGame() { - try { - const cRes = await fetch('/api/config'); - gameConfig = await cRes.json(); - - await loadAssets(); - - await loadStartScreenLeaderboard(); - - isLoaded = true; - if(loadingText) loadingText.style.display = 'none'; - if(startBtn) startBtn.style.display = 'inline-block'; - - const savedHighscore = localStorage.getItem('escape_highscore') || 0; - const hsEl = document.getElementById('localHighscore'); - if(hsEl) hsEl.innerText = savedHighscore; - - requestAnimationFrame(gameLoop); - } catch(e) { - if(loadingText) loadingText.innerText = "Fehler!"; - } -} - -// Lädt die Top-Liste für den Startbildschirm (ohne Session ID) -async function loadStartScreenLeaderboard() { - try { - const listEl = document.getElementById('startLeaderboardList'); - if (!listEl) return; - - // Anfrage an API (ohne SessionID gibt der Server automatisch die Top 3 zurück) - const res = await fetch('/api/leaderboard'); + async function loadLeaderboard() { + const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`); const entries = await res.json(); - - if (entries.length === 0) { - listEl.innerHTML = "
Noch keine Scores.
"; - return; - } - - let html = ""; + let html = "

BESTENLISTE

"; entries.forEach(e => { - // Medaillen Icons für Top 3 - let icon = "#" + e.rank; - if (e.rank === 1) icon = "🥇"; - if (e.rank === 2) icon = "🥈"; - if (e.rank === 3) icon = "🥉"; - - html += ` -
- ${icon} ${e.name} - ${Math.floor(e.score / 10)} -
`; + const color = e.isMe ? "yellow" : "white"; + html += `
+ #${e.rank} ${e.name}${Math.floor(e.score/10)}
`; }); - - listEl.innerHTML = html; - } catch (e) { - console.error("Konnte Leaderboard nicht laden", e); + document.getElementById('leaderboard').innerHTML = html; } -} -initGame(); \ No newline at end of file + 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 = "
Noch keine Scores.
"; 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 += `
${icon} ${e.name}${Math.floor(e.score / 10)}
`; + }); + 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(); + } + + function drawGame() { + ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + if (bgSprite.complete && bgSprite.naturalHeight !== 0) ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT); + else { ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); } + ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50); + obstacles.forEach(obs => { + const img = sprites[obs.def.id]; + if (img) ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height); + else { ctx.fillStyle = obs.def.color; ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height); } + if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech); + }); + ctx.strokeStyle = isGameOver ? "red" : "lime"; ctx.lineWidth = 2; + serverObstacles.forEach(srvObs => ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h)); + const drawY = isCrouching ? player.y + 25 : player.y; const drawH = isCrouching ? 25 : 50; + if (playerSprite.complete && playerSprite.naturalHeight !== 0) ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH); + else { ctx.fillStyle = player.color; ctx.fillRect(player.x, drawY, player.w, drawH); } + if (isGameOver) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); } + } + + function drawSpeechBubble(x, y, text) { + const bX = x-20; const bY = y-40; const bW = 120; const bH = 30; + ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH); ctx.strokeRect(bX,bY,bW,bH); + ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center"; ctx.fillText(text, bX+bW/2, bY+20); + } + + function gameLoop() { + if (!isLoaded) return; + if (isGameRunning && !isGameOver) { + updateGameLogic(); currentTick++; score++; + const scoreEl = document.getElementById('score'); if (scoreEl) scoreEl.innerText = Math.floor(score / 10); + if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk(); + } + drawGame(); requestAnimationFrame(gameLoop); + } + + async function initGame() { + try { + const cRes = await fetch('/api/config'); gameConfig = await cRes.json(); + await loadAssets(); + await loadStartScreenLeaderboard(); + isLoaded = true; + if(loadingText) loadingText.style.display = 'none'; if(startBtn) startBtn.style.display = 'inline-block'; + const savedHighscore = localStorage.getItem('escape_highscore') || 0; + const hsEl = document.getElementById('localHighscore'); if(hsEl) hsEl.innerText = savedHighscore; + requestAnimationFrame(gameLoop); + } catch(e) { if(loadingText) loadingText.innerText = "Fehler!"; } + } + + initGame(); + +})(); \ No newline at end of file diff --git a/static/index.html b/static/index.html index fab8fd2..d7615b4 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,7 @@ Escape the Teacher + @@ -22,8 +23,10 @@
+

ESCAPE THE
TEACHER

+

Dein Rekord: 0

@@ -34,22 +37,22 @@

• Herr Müller verteilt heute Nachsitzen!
• Spring über Tische und Mülleimer.
- • Lass dich nicht erwischen! + • Neu: Ducken (Pfeil Runter) gegen fliegende Schwämme!

STEUERUNG

- PC: Leertaste, Pfeil Hoch/Runter oder Mausklick
- Handy: Tippen (Springen) oder Wischen (Ducken) + PC: Leertaste/Maus (Springen), Pfeil Runter (Ducken)
+ Handy: Tippen (Springen), Wischen nach unten (Ducken)

@@ -76,24 +79,28 @@ - - + + @@ -101,81 +108,54 @@ - + + + + + + + + \ No newline at end of file diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..ffd7073 --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,28 @@ +// Konstanten +const GAME_WIDTH = 800; +const GAME_HEIGHT = 400; +const GRAVITY = 0.6; +const JUMP_POWER = -12; +const GROUND_Y = 350; +const GAME_SPEED = 5; +const CHUNK_SIZE = 60; + +// RNG Klasse +class PseudoRNG { + constructor(seed) { + this.state = BigInt(seed); + } + nextFloat() { + const a = 1664525n; const c = 1013904223n; const m = 4294967296n; + this.state = (this.state * a + c) % m; + return Number(this.state) / Number(m); + } + nextRange(min, max) { + return min + (this.nextFloat() * (max - min)); + } + pick(array) { + if (!array || array.length === 0) return null; + const idx = Math.floor(this.nextRange(0, array.length)); + return array[idx]; + } +} \ No newline at end of file diff --git a/static/js/input.js b/static/js/input.js new file mode 100644 index 0000000..916b772 --- /dev/null +++ b/static/js/input.js @@ -0,0 +1,45 @@ +function handleInput(action, active) { + if (isGameOver) { if(active) location.reload(); return; } + + const relativeTick = currentTick - lastSentTick; + + if (action === "JUMP" && active) { + if (player.grounded && !isCrouching) { + player.vy = JUMP_POWER; + player.grounded = false; + inputLog.push({ t: relativeTick, act: "JUMP" }); + } + } + if (action === "DUCK") { isCrouching = active; } +} + +// Event Listeners +window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true); + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true); +}); +window.addEventListener('keyup', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false); +}); +window.addEventListener('mousedown', (e) => { + if (e.target === canvas && e.button === 0) handleInput("JUMP", true); +}); + +// Touch Logic +let touchStartY = 0; +window.addEventListener('touchstart', (e) => { + if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; } +}, { passive: false }); +window.addEventListener('touchend', (e) => { + if(e.target === canvas) { + e.preventDefault(); + const diff = e.changedTouches[0].clientY - touchStartY; + if (diff < -30) handleInput("JUMP", true); + else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); } + else if (Math.abs(diff) < 10) handleInput("JUMP", true); + } +}); \ No newline at end of file diff --git a/static/js/logic.js b/static/js/logic.js new file mode 100644 index 0000000..e7d6ddc --- /dev/null +++ b/static/js/logic.js @@ -0,0 +1,59 @@ +function updateGameLogic() { + if (isCrouching) { + inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" }); + } + + const originalHeight = 50; const crouchHeight = 25; + player.h = isCrouching ? crouchHeight : originalHeight; + let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y; + + player.vy += GRAVITY; + if (isCrouching && !player.grounded) player.vy += 2.0; + player.y += player.vy; + + if (player.y + originalHeight >= GROUND_Y) { + player.y = GROUND_Y - originalHeight; player.vy = 0; player.grounded = true; + } else { player.grounded = false; } + + let nextObstacles = []; let rightmostX = 0; + for (let obs of obstacles) { + obs.x -= GAME_SPEED; + const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h }; + + if (checkCollision(playerHitbox, obs)) { + player.color = "darkred"; + if (!isGameOver) { sendChunk(); gameOver("Kollision"); } + } + if (obs.x + obs.def.width > -100) { + nextObstacles.push(obs); + if (obs.x + obs.def.width > rightmostX) rightmostX = obs.x + obs.def.width; + } + } + obstacles = nextObstacles; + + // Spawning + if (rightmostX < GAME_WIDTH - 10 && gameConfig) { + const gap = Math.floor(400 + rng.nextRange(0, 500)); + let spawnX = rightmostX + gap; if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + + let possibleObs = []; + gameConfig.obstacles.forEach(def => { + if (def.id === "eraser") { if (score >= 500) possibleObs.push(def); } else possibleObs.push(def); + }); + const def = rng.pick(possibleObs); + + let speech = null; + if (def && def.canTalk) { if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); } + + if (def) { + const yOffset = def.yOffset || 0; + obstacles.push({ x: spawnX, y: GROUND_Y - def.height - yOffset, def: def, speech: speech }); + } + } +} + +function checkCollision(p, obs) { + const paddingX = 10; const paddingY_Top = 25; const paddingY_Bottom = 5; + return (p.x + p.w - paddingX > obs.x + paddingX && p.x + paddingX < obs.x + obs.def.width - paddingX && + p.y + p.h - paddingY_Bottom > obs.y + paddingY_Top && p.y + paddingY_Top < obs.y + obs.def.height - paddingY_Bottom); +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..f6f2f53 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,70 @@ +async function loadAssets() { + playerSprite.src = "assets/player.png"; + if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) { + const bgName = gameConfig.backgrounds[0]; + if (!bgName.startsWith("#")) bgSprite.src = "assets/" + bgName; + } + const promises = 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(); }; + }); + }); + if (bgSprite.src) { + promises.push(new Promise(r => { bgSprite.onload = r; bgSprite.onerror = r; })); + } + await Promise.all(promises); +} + +window.startGameClick = async function() { + if (!isLoaded) return; + startScreen.style.display = 'none'; + document.body.classList.add('game-active'); // Handy Rotate Check + try { + const sRes = await fetch('/api/start', {method:'POST'}); + const sData = await sRes.json(); + sessionID = sData.sessionId; + rng = new PseudoRNG(sData.seed); + isGameRunning = true; + resize(); + } catch(e) { location.reload(); } +}; + +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(); +} + +function gameLoop() { + if (!isLoaded) return; + if (isGameRunning && !isGameOver) { + updateGameLogic(); currentTick++; score++; + const scoreEl = document.getElementById('score'); if (scoreEl) scoreEl.innerText = Math.floor(score / 10); + if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk(); + } + drawGame(); requestAnimationFrame(gameLoop); +} + +async function initGame() { + try { + const cRes = await fetch('/api/config'); gameConfig = await cRes.json(); + await loadAssets(); + await loadStartScreenLeaderboard(); + isLoaded = true; + if(loadingText) loadingText.style.display = 'none'; if(startBtn) startBtn.style.display = 'inline-block'; + const savedHighscore = localStorage.getItem('escape_highscore') || 0; + const hsEl = document.getElementById('localHighscore'); if(hsEl) hsEl.innerText = savedHighscore; + requestAnimationFrame(gameLoop); + } catch(e) { if(loadingText) loadingText.innerText = "Fehler!"; } +} + +initGame(); \ No newline at end of file diff --git a/static/js/network.js b/static/js/network.js new file mode 100644 index 0000000..a832434 --- /dev/null +++ b/static/js/network.js @@ -0,0 +1,92 @@ +async function sendChunk() { + const ticksToSend = currentTick - lastSentTick; + if (ticksToSend <= 0) return; + + const payload = { + sessionId: sessionID, + inputs: [...inputLog], + totalTicks: ticksToSend + }; + + inputLog = []; + lastSentTick = currentTick; + + try { + const res = await fetch('/api/validate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + + const data = await res.json(); + + if (data.serverObs) serverObstacles = data.serverObs; + + if (data.status === "dead") { + console.error("SERVER TOT", data); + gameOver("Vom Server gestoppt"); + } else { + const sScore = data.verifiedScore; + if (Math.abs(score - sScore) > 200) score = sScore; + } + } catch (e) { + console.error("Netzwerkfehler:", e); + } +} + +window.submitScore = async function() { + const nameInput = document.getElementById('playerNameInput'); + const name = nameInput.value; + const btn = document.getElementById('submitBtn'); + + if (!name) return alert("Namen eingeben!"); + btn.disabled = true; + + try { + const res = await fetch('/api/submit-name', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ sessionId: sessionID, name: name }) + }); + const data = await res.json(); + + let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]'); + myClaims.push({ + name: name, score: Math.floor(score / 10), code: data.claimCode, + date: new Date().toLocaleString('de-DE'), sessionId: sessionID + }); + localStorage.setItem('escape_claims', JSON.stringify(myClaims)); + + document.getElementById('inputSection').style.display = 'none'; + loadLeaderboard(); + } catch (e) {} +}; + +async function loadLeaderboard() { + const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`); + const entries = await res.json(); + let html = "

BESTENLISTE

"; + entries.forEach(e => { + const color = e.isMe ? "yellow" : "white"; + html += `
+ #${e.rank} ${e.name}${Math.floor(e.score/10)}
`; + }); + 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 = "
Noch keine Scores.
"; 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 += `
${icon} ${e.name}${Math.floor(e.score / 10)}
`; + }); + listEl.innerHTML = html; + } catch (e) {} +} \ No newline at end of file diff --git a/static/js/render.js b/static/js/render.js new file mode 100644 index 0000000..d6985a6 --- /dev/null +++ b/static/js/render.js @@ -0,0 +1,107 @@ +function resize() { + // 1. INTERNE SPIEL-AUFLÖSUNG ERZWINGEN + // Das behebt den "Zoom/Nur Ecke sichtbar" Fehler + canvas.width = GAME_WIDTH; // 800 + canvas.height = GAME_HEIGHT; // 400 + + // 2. Verfügbaren Platz im Browser berechnen (Minus etwas Rand) + const windowWidth = window.innerWidth - 20; + const windowHeight = window.innerHeight - 20; + + const targetRatio = GAME_WIDTH / GAME_HEIGHT; // 2.0 + const windowRatio = windowWidth / windowHeight; + + let finalWidth, finalHeight; + + // 3. Letterboxing berechnen + if (windowRatio < targetRatio) { + // Screen ist schmaler (z.B. Handy Portrait) -> Breite limitiert + finalWidth = windowWidth; + finalHeight = windowWidth / targetRatio; + } else { + // Screen ist breiter (z.B. Desktop) -> Höhe limitiert + finalHeight = windowHeight; + finalWidth = finalHeight * targetRatio; + } + + // 4. Größe auf den CONTAINER anwenden + if (container) { + container.style.width = `${Math.floor(finalWidth)}px`; + container.style.height = `${Math.floor(finalHeight)}px`; + } + + // Hinweis: Wir setzen KEINE style.width/height auf das Canvas Element selbst. + // Das Canvas erbt "width: 100%; height: 100%" vom CSS und füllt den Container. +} + +// Event Listener +window.addEventListener('resize', resize); + +// Einmal sofort ausführen +resize(); + + +// --- DRAWING --- + +function drawGame() { + // Alles löschen + ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + // Background + if (bgSprite.complete && bgSprite.naturalHeight !== 0) { + // Hintergrundbild exakt auf 800x400 skalieren + ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT); + } else { + // Fallback Farbe + ctx.fillStyle = "#f0f0f0"; + ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + + // Boden + ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; + ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50); + + // Hindernisse + obstacles.forEach(obs => { + const img = sprites[obs.def.id]; + if (img) { + ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height); + } else { + ctx.fillStyle = obs.def.color; + 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) + ctx.strokeStyle = isGameOver ? "red" : "lime"; + ctx.lineWidth = 2; + serverObstacles.forEach(srvObs => { + ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h); + }); + + // Spieler + const drawY = isCrouching ? player.y + 25 : player.y; + const drawH = isCrouching ? 25 : 50; + + if (playerSprite.complete && playerSprite.naturalHeight !== 0) { + ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH); + } else { + ctx.fillStyle = player.color; + ctx.fillRect(player.x, drawY, player.w, drawH); + } + + // Game Over Overlay (Dunkelheit) + if (isGameOver) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); + } +} + +function drawSpeechBubble(x, y, text) { + const bX = x-20; const bY = y-40; const bW = 120; const bH = 30; + ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH); + ctx.strokeRect(bX,bY,bW,bH); + ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center"; + ctx.fillText(text, bX+bW/2, bY+20); +} \ No newline at end of file diff --git a/static/js/state.js b/static/js/state.js new file mode 100644 index 0000000..d382ec6 --- /dev/null +++ b/static/js/state.js @@ -0,0 +1,36 @@ +// Globale Status-Variablen +let gameConfig = null; +let isLoaded = false; +let isGameRunning = false; +let isGameOver = false; +let sessionID = null; + +let rng = null; +let score = 0; +let currentTick = 0; +let lastSentTick = 0; +let inputLog = []; +let isCrouching = false; + +// Grafiken +let sprites = {}; +let playerSprite = new Image(); +let bgSprite = new Image(); + +// Spiel-Objekte +let player = { + x: 50, y: 300, w: 30, h: 50, color: "red", + vy: 0, grounded: false +}; + +let obstacles = []; +let serverObstacles = []; + +// HTML Elemente (Caching) +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); +const container = document.getElementById('game-container'); +const startScreen = document.getElementById('startScreen'); +const startBtn = document.getElementById('startBtn'); +const loadingText = document.getElementById('loadingText'); +const gameOverScreen = document.getElementById('gameOverScreen'); \ No newline at end of file diff --git a/static/style.css b/static/style.css index 6842036..abb4d27 100644 --- a/static/style.css +++ b/static/style.css @@ -35,17 +35,18 @@ body, html { box-shadow: 0 0 50px rgba(0,0,0,0.8); border: 4px solid #444; background: #000; - max-width: 100%; - max-height: 100%; + /* Größe wird von JS gesetzt, hier nur Layout-Verhalten */ + display: flex; + overflow: hidden; } canvas { display: block; + width: 100%; + height: 100%; background-color: #f0f0f0; image-rendering: pixelated; image-rendering: crisp-edges; - width: 100%; - height: auto; } /* ========================================= @@ -64,7 +65,7 @@ canvas { } /* ========================================= - 3. OVERLAYS (Start, Game Over) + 3. OVERLAYS (Basis-Einstellungen) ========================================= */ #startScreen, #gameOverScreen { position: absolute; @@ -89,21 +90,21 @@ h1 { text-transform: uppercase; } -/* --- FIX: INPUT SECTION ZENTRIEREN --- */ +/* Fix für Input Section (Name eingeben) */ #inputSection { display: flex; - flex-direction: column; /* Untereinander */ - align-items: center; /* Horizontal mittig */ + flex-direction: column; + align-items: center; justify-content: center; width: 100%; margin: 15px 0; } /* ========================================= - 4. START SCREEN LAYOUT + 4. START SCREEN (Links/Rechts Layout) ========================================= */ #startScreen { - flex-direction: row; + flex-direction: row; /* Nebeneinander */ gap: 40px; } @@ -125,6 +126,32 @@ h1 { max-width: 35%; } +/* ========================================= + 5. GAME OVER SCREEN (WICHTIG: Untereinander) + ========================================= */ +#gameOverScreen { + /* HIER IST DER FIX: */ + flex-direction: column !important; + gap: 15px; +} + +/* Das Leaderboard im Game Over Screen */ +#leaderboard { + margin-top: 10px; + font-size: 12px; + width: 90%; + max-width: 450px; + background: rgba(0,0,0,0.5); + padding: 15px; + border: 2px solid #666; + /* Begrenzte Höhe mit Scrollen, falls Liste lang ist */ + max-height: 200px; + overflow-y: auto; +} + +/* ========================================= + 6. HALL OF FAME BOX (Startseite) + ========================================= */ .hall-of-fame-box { background: rgba(0, 0, 0, 0.6); border: 4px solid #ffcc00; @@ -159,7 +186,7 @@ h1 { .hof-score { color: white; font-weight: bold; } /* ========================================= - 5. BUTTONS & INPUTS + 7. BUTTONS & INPUTS ========================================= */ button { font-family: 'Press Start 2P', cursive; @@ -190,7 +217,7 @@ input { } /* ========================================= - 6. INFO BOXEN + 8. INFO BOXEN ========================================= */ .info-box { background: rgba(255, 255, 255, 0.1); @@ -217,20 +244,8 @@ input { text-decoration: underline; } -/* Game Over Screen Anpassung */ -#gameOverScreen { flex-direction: column; } -#leaderboard { - margin-top: 20px; - font-size: 12px; - width: 90%; - max-width: 450px; - background: rgba(0,0,0,0.5); - padding: 15px; - border: 2px solid #666; -} - /* ========================================= - 7. RECHTLICHES + 9. RECHTLICHES ========================================= */ .legal-bar { margin-top: 20px; @@ -241,8 +256,14 @@ input { font-size: 10px; padding: 8px 12px; margin: 0; + background: transparent; + border: 1px solid #666; + color: #888; + box-shadow: none; } +.legal-btn:hover { background: #333; color: white; border-color: white; } +/* Modals */ .modal-overlay { display: none; position: fixed; @@ -275,10 +296,12 @@ input { width: 35px; height: 35px; font-size: 16px; line-height: 30px; + margin: 0; padding: 0; + box-shadow: none; } /* ========================================= - 8. PC / DESKTOP SPEZIAL + 10. PC / DESKTOP SPEZIAL ========================================= */ @media (min-width: 1024px) { h1 { font-size: 48px; margin-bottom: 40px; } @@ -295,7 +318,7 @@ input { } /* ========================================= - 9. MOBILE ANPASSUNG + 11. MOBILE ANPASSUNG ========================================= */ @media (max-width: 700px) { #startScreen { @@ -313,7 +336,7 @@ input { } /* ========================================= - 10. ROTATE OVERLAY + 12. ROTATE OVERLAY ========================================= */ #rotate-overlay { display: none; @@ -329,7 +352,8 @@ input { } .icon { font-size: 60px; margin-bottom: 20px; } +/* Nur anzeigen, wenn Spiel läuft UND Portrait */ @media screen and (orientation: portrait) { - #rotate-overlay { display: flex; } - #game-container { display: none !important; } + body.game-active #rotate-overlay { display: flex; } + body.game-active #game-container { display: none !important; } } \ No newline at end of file diff --git a/types.go b/types.go new file mode 100644 index 0000000..1c22b24 --- /dev/null +++ b/types.go @@ -0,0 +1,83 @@ +package main + +type ObstacleDef struct { + ID string `json:"id"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Color string `json:"color"` + Image string `json:"image"` + CanTalk bool `json:"canTalk"` + SpeechLines []string `json:"speechLines"` + YOffset float64 `json:"yOffset"` +} + +type GameConfig struct { + Obstacles []ObstacleDef `json:"obstacles"` + Backgrounds []string `json:"backgrounds"` +} + +// Dynamischer State +type ActiveObstacle struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"w"` + Height float64 `json:"h"` +} + +// API Requests/Responses +type Input struct { + Tick int `json:"t"` + Act string `json:"act"` +} + +type ValidateRequest struct { + SessionID string `json:"sessionId"` + Inputs []Input `json:"inputs"` + TotalTicks int `json:"totalTicks"` +} + +type ValidateResponse struct { + Status string `json:"status"` + VerifiedScore int `json:"verifiedScore"` + ServerObs []ActiveObstacle `json:"serverObs"` +} + +type StartResponse struct { + SessionID string `json:"sessionId"` + Seed uint32 `json:"seed"` +} + +type SubmitNameRequest struct { + SessionID string `json:"sessionId"` + Name string `json:"name"` +} + +type SubmitResponse struct { + ClaimCode string `json:"claimCode"` +} + +type LeaderboardEntry struct { + Rank int64 `json:"rank"` + Name string `json:"name"` + Score int `json:"score"` + IsMe bool `json:"isMe"` +} + +type AdminActionRequest struct { + SessionID string `json:"sessionId"` + Action string `json:"action"` +} + +type AdminEntry struct { + SessionID string `json:"sessionId"` + Name string `json:"name"` + Score int `json:"score"` + Code string `json:"code"` + Time string `json:"time"` +} + +type ClaimDeleteRequest struct { + SessionID string `json:"sessionId"` + ClaimCode string `json:"claimCode"` +}