Merge pull request 'add-new-player-skin' (#3) from add-new-player-skin into main
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m49s
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m49s
Reviewed-on: #3
This commit is contained in:
60
Dockerfile
60
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.25-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Abhängigkeiten kopieren und herunterladen (Caching-Effizienz)
|
# Abhängigkeiten laden
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Den gesamten Rest des Codes kopieren
|
# Restlichen Code kopieren
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Das Go-Programm kompilieren
|
# Bauen (Der Punkt am Ende heißt: Alle Dateien im Ordner nutzen)
|
||||||
# -o server: Nenne die Datei "server"
|
RUN go build -o server .
|
||||||
RUN go build -o server main.go
|
|
||||||
|
|
||||||
# --- STAGE 2: Runner (Ausführen) ---
|
# ==========================================
|
||||||
|
# STAGE 3: Final Runner (Production)
|
||||||
|
# ==========================================
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
WORKDIR /root/
|
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
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
# Kopiere das fertige Programm aus Stage 1
|
# 1. Server Binary kopieren
|
||||||
COPY --from=builder /app/server .
|
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/static ./static
|
||||||
COPY --from=builder /app/secure ./secure
|
COPY --from=builder /app/secure ./secure
|
||||||
|
|
||||||
# Port freigeben
|
# 3. Das MINIFIZIERTE JS kopieren (als einziges Script)
|
||||||
EXPOSE 8080
|
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 <script src="js/... enthalten
|
||||||
|
# Schritt B: Füge vor dem schließenden </body> Tag das neue Script ein
|
||||||
|
RUN sed -i '/<script src="js\//d' static/index.html \
|
||||||
|
&& sed -i '/<\/body>/i \ <script src="js/game.min.js"></script>' static/index.html
|
||||||
|
|
||||||
|
# Port und Start
|
||||||
|
EXPOSE 8080
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
48
config.go
Normal file
48
config.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
233
handlers.go
Normal file
233
handlers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
674
main.go
674
main.go
@@ -1,192 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"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 {
|
func BasicAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, pass, ok := r.BasicAuth()
|
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() {
|
func main() {
|
||||||
// Config laden
|
|
||||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||||
adminUser = getEnv("ADMIN_USER", "lehrer")
|
adminUser = getEnv("ADMIN_USER", "lehrer")
|
||||||
adminPass = getEnv("ADMIN_PASS", "geheim123")
|
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})
|
rdb = redis.NewClient(&redis.Options{Addr: redisAddr})
|
||||||
|
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||||
pong, err := rdb.Ping(ctx).Result()
|
log.Fatal("Redis:", err)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("❌ REDIS ERROR: %v", err)
|
|
||||||
}
|
}
|
||||||
log.Printf("✅ Redis verbunden: %s", pong)
|
|
||||||
|
|
||||||
initGameConfig()
|
initGameConfig()
|
||||||
|
|
||||||
// File Server
|
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
fs := http.FileServer(http.Dir("./static"))
|
||||||
http.Handle("/", fs)
|
http.Handle("/", fs)
|
||||||
|
|
||||||
// API Routes (mit Logging Wrapper)
|
http.HandleFunc("/api/config", handleConfig)
|
||||||
http.HandleFunc("/api/config", LogRequest(handleConfig))
|
http.HandleFunc("/api/start", handleStart)
|
||||||
http.HandleFunc("/api/start", LogRequest(handleStart))
|
http.HandleFunc("/api/validate", handleValidate)
|
||||||
http.HandleFunc("/api/validate", LogRequest(handleValidate)) // Hier passiert die Magie
|
http.HandleFunc("/api/submit-name", handleSubmitName)
|
||||||
http.HandleFunc("/api/submit-name", LogRequest(handleSubmitName))
|
http.HandleFunc("/api/leaderboard", handleLeaderboard)
|
||||||
http.HandleFunc("/api/leaderboard", LogRequest(handleLeaderboard))
|
http.HandleFunc("/api/claim/delete", handleClaimDelete)
|
||||||
http.HandleFunc("/api/claim/delete", LogRequest(handleClaimDelete))
|
|
||||||
|
|
||||||
// Admin Routes
|
|
||||||
http.HandleFunc("/admin", BasicAuth(handleAdminPage))
|
http.HandleFunc("/admin", BasicAuth(handleAdminPage))
|
||||||
http.HandleFunc("/api/admin/list", BasicAuth(handleAdminList))
|
http.HandleFunc("/api/admin/list", BasicAuth(handleAdminList))
|
||||||
http.HandleFunc("/api/admin/action", BasicAuth(handleAdminAction))
|
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))
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
27
rng.go
Normal file
27
rng.go
Normal file
@@ -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]
|
||||||
|
}
|
||||||
230
simulation.go
Normal file
230
simulation.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
410
static/game.js
410
static/game.js
@@ -1,3 +1,5 @@
|
|||||||
|
(function() {
|
||||||
|
|
||||||
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');
|
||||||
@@ -11,19 +13,14 @@ class PseudoRNG {
|
|||||||
constructor(seed) {
|
constructor(seed) {
|
||||||
this.state = BigInt(seed);
|
this.state = BigInt(seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextFloat() {
|
nextFloat() {
|
||||||
const a = 1664525n;
|
const a = 1664525n; const c = 1013904223n; const m = 4294967296n;
|
||||||
const c = 1013904223n;
|
|
||||||
const m = 4294967296n;
|
|
||||||
this.state = (this.state * a + c) % m;
|
this.state = (this.state * a + c) % m;
|
||||||
return Number(this.state) / Number(m);
|
return Number(this.state) / Number(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRange(min, max) {
|
nextRange(min, max) {
|
||||||
return min + (this.nextFloat() * (max - min));
|
return min + (this.nextFloat() * (max - min));
|
||||||
}
|
}
|
||||||
|
|
||||||
pick(array) {
|
pick(array) {
|
||||||
if (!array || array.length === 0) return null;
|
if (!array || array.length === 0) return null;
|
||||||
const idx = Math.floor(this.nextRange(0, array.length));
|
const idx = Math.floor(this.nextRange(0, array.length));
|
||||||
@@ -31,202 +28,133 @@ class PseudoRNG {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const GAME_WIDTH = 800;
|
// Config
|
||||||
const GAME_HEIGHT = 400;
|
const GAME_WIDTH = 800; const GAME_HEIGHT = 400;
|
||||||
canvas.width = GAME_WIDTH;
|
canvas.width = GAME_WIDTH; canvas.height = GAME_HEIGHT;
|
||||||
canvas.height = GAME_HEIGHT;
|
const GRAVITY = 0.6; const JUMP_POWER = -12; const GROUND_Y = 350;
|
||||||
|
const GAME_SPEED = 5; const CHUNK_SIZE = 60;
|
||||||
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 gameConfig = null;
|
||||||
let isLoaded = false;
|
let isLoaded = false;
|
||||||
let isGameRunning = false;
|
let isGameRunning = false;
|
||||||
let isGameOver = false;
|
let isGameOver = false;
|
||||||
let sessionID = null;
|
let sessionID = null;
|
||||||
|
|
||||||
let rng = null;
|
let rng = null;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
let currentTick = 0;
|
let currentTick = 0;
|
||||||
let lastSentTick = 0;
|
let lastSentTick = 0;
|
||||||
let inputLog = [];
|
let inputLog = [];
|
||||||
let isCrouching = false;
|
let isCrouching = false;
|
||||||
|
|
||||||
let sprites = {};
|
let sprites = {};
|
||||||
let playerSprite = new Image();
|
let playerSprite = new Image();
|
||||||
let bgSprite = new Image();
|
let bgSprite = new Image();
|
||||||
|
let player = { x: 50, y: 300, w: 30, h: 50, color: "red", vy: 0, grounded: false };
|
||||||
let player = {
|
|
||||||
x: 50, y: 300, w: 30, h: 50, color: "red",
|
|
||||||
vy: 0, grounded: false
|
|
||||||
};
|
|
||||||
|
|
||||||
let obstacles = [];
|
let obstacles = [];
|
||||||
|
let serverObstacles = [];
|
||||||
|
|
||||||
|
// --- Funktionen ---
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const windowWidth = window.innerWidth;
|
const windowWidth = window.innerWidth;
|
||||||
const windowHeight = window.innerHeight;
|
const windowHeight = window.innerHeight;
|
||||||
const targetRatio = GAME_WIDTH / GAME_HEIGHT;
|
const targetRatio = GAME_WIDTH / GAME_HEIGHT;
|
||||||
|
|
||||||
let finalWidth, finalHeight;
|
let finalWidth, finalHeight;
|
||||||
if (windowWidth / windowHeight < targetRatio) {
|
if (windowWidth / windowHeight < targetRatio) {
|
||||||
finalWidth = windowWidth;
|
finalWidth = windowWidth; finalHeight = windowWidth / targetRatio;
|
||||||
finalHeight = windowWidth / targetRatio;
|
|
||||||
} else {
|
} else {
|
||||||
finalHeight = windowHeight;
|
finalHeight = windowHeight; finalWidth = finalHeight * targetRatio;
|
||||||
finalWidth = finalHeight * targetRatio;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.style.width = `${finalWidth}px`;
|
canvas.style.width = `${finalWidth}px`;
|
||||||
canvas.style.height = `${finalHeight}px`;
|
canvas.style.height = `${finalHeight}px`;
|
||||||
if(container) {
|
if(container) { container.style.width = `${finalWidth}px`; container.style.height = `${finalHeight}px`; }
|
||||||
container.style.width = `${finalWidth}px`;
|
|
||||||
container.style.height = `${finalHeight}px`;
|
|
||||||
}
|
}
|
||||||
}
|
window.addEventListener('resize', resize); resize();
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
async function loadAssets() {
|
async function loadAssets() {
|
||||||
playerSprite.src = "assets/player.gif";
|
playerSprite.src = "assets/player.png";
|
||||||
|
|
||||||
if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) {
|
if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) {
|
||||||
const bgName = gameConfig.backgrounds[0];
|
const bgName = gameConfig.backgrounds[0];
|
||||||
if (!bgName.startsWith("#")) {
|
if (!bgName.startsWith("#")) bgSprite.src = "assets/" + bgName;
|
||||||
bgSprite.src = "assets/" + bgName;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const promises = gameConfig.obstacles.map(def => {
|
const promises = gameConfig.obstacles.map(def => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!def.image) { resolve(); return; }
|
if (!def.image) { resolve(); return; }
|
||||||
const img = new Image();
|
const img = new Image(); img.src = "assets/" + def.image;
|
||||||
img.src = "assets/" + def.image;
|
|
||||||
img.onload = () => { sprites[def.id] = img; resolve(); };
|
img.onload = () => { sprites[def.id] = img; resolve(); };
|
||||||
img.onerror = () => { resolve(); };
|
img.onerror = () => { resolve(); };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bgSprite.src) {
|
if (bgSprite.src) {
|
||||||
promises.push(new Promise(r => {
|
promises.push(new Promise(r => { bgSprite.onload = r; bgSprite.onerror = r; }));
|
||||||
bgSprite.onload = r;
|
|
||||||
bgSprite.onerror = r;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global verfügbar machen für HTML Button
|
||||||
window.startGameClick = async function() {
|
window.startGameClick = async function() {
|
||||||
if (!isLoaded) return;
|
if (!isLoaded) return;
|
||||||
startScreen.style.display = 'none';
|
startScreen.style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sRes = await fetch('/api/start', {method:'POST'});
|
const sRes = await fetch('/api/start', {method:'POST'});
|
||||||
const sData = await sRes.json();
|
const sData = await sRes.json();
|
||||||
|
|
||||||
sessionID = sData.sessionId;
|
sessionID = sData.sessionId;
|
||||||
rng = new PseudoRNG(sData.seed);
|
rng = new PseudoRNG(sData.seed);
|
||||||
isGameRunning = true;
|
isGameRunning = true;
|
||||||
} catch(e) {
|
} catch(e) { location.reload(); }
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleInput(action, active) {
|
function handleInput(action, active) {
|
||||||
if (isGameOver) { if(active) location.reload(); return; }
|
if (isGameOver) { if(active) location.reload(); return; }
|
||||||
|
|
||||||
const relativeTick = currentTick - lastSentTick;
|
const relativeTick = currentTick - lastSentTick;
|
||||||
|
|
||||||
if (action === "JUMP" && active) {
|
if (action === "JUMP" && active) {
|
||||||
if (player.grounded && !isCrouching) {
|
if (player.grounded && !isCrouching) {
|
||||||
player.vy = JUMP_POWER;
|
player.vy = JUMP_POWER; player.grounded = false;
|
||||||
player.grounded = false;
|
|
||||||
inputLog.push({ t: relativeTick, act: "JUMP" });
|
inputLog.push({ t: relativeTick, act: "JUMP" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (action === "DUCK") { isCrouching = active; }
|
||||||
if (action === "DUCK") {
|
|
||||||
isCrouching = active;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
|
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('mousedown', (e) => {
|
window.addEventListener('mousedown', (e) => {
|
||||||
if (e.target === canvas && e.button === 0) {
|
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
|
||||||
handleInput("JUMP", true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let touchStartY = 0;
|
let touchStartY = 0;
|
||||||
window.addEventListener('touchstart', (e) => {
|
window.addEventListener('touchstart', (e) => {
|
||||||
if(e.target === canvas) {
|
if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; }
|
||||||
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 touchEndY = e.changedTouches[0].clientY;
|
const diff = e.changedTouches[0].clientY - touchStartY;
|
||||||
const diff = touchEndY - touchStartY;
|
if (diff < -30) handleInput("JUMP", true);
|
||||||
|
else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); }
|
||||||
if (diff < -30) {
|
else if (Math.abs(diff) < 10) handleInput("JUMP", true);
|
||||||
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() {
|
async function sendChunk() {
|
||||||
const ticksToSend = currentTick - lastSentTick;
|
const ticksToSend = currentTick - lastSentTick;
|
||||||
if (ticksToSend <= 0) return;
|
if (ticksToSend <= 0) return;
|
||||||
|
const payload = { sessionId: sessionID, inputs: [...inputLog], totalTicks: ticksToSend };
|
||||||
const payload = {
|
inputLog = []; lastSentTick = currentTick;
|
||||||
sessionId: sessionID,
|
|
||||||
inputs: [...inputLog],
|
|
||||||
totalTicks: ticksToSend
|
|
||||||
};
|
|
||||||
|
|
||||||
inputLog = [];
|
|
||||||
lastSentTick = currentTick;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/validate', {
|
const res = await fetch('/api/validate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (data.serverObs) serverObstacles = data.serverObs;
|
||||||
if (data.status === "dead") {
|
if (data.status === "dead") gameOver("Vom Server gestoppt");
|
||||||
gameOver("Vom Server gestoppt");
|
else {
|
||||||
} else {
|
|
||||||
const sScore = data.verifiedScore;
|
const sScore = data.verifiedScore;
|
||||||
if (Math.abs(score - sScore) > 200) {
|
if (Math.abs(score - sScore) > 200) score = sScore;
|
||||||
score = sScore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGameLogic() {
|
function updateGameLogic() {
|
||||||
@@ -234,147 +162,76 @@ function updateGameLogic() {
|
|||||||
const relativeTick = currentTick - lastSentTick;
|
const relativeTick = currentTick - lastSentTick;
|
||||||
inputLog.push({ t: relativeTick, act: "DUCK" });
|
inputLog.push({ t: relativeTick, act: "DUCK" });
|
||||||
}
|
}
|
||||||
|
const originalHeight = 50; const crouchHeight = 25;
|
||||||
const originalHeight = 50;
|
|
||||||
const crouchHeight = 25;
|
|
||||||
player.h = isCrouching ? crouchHeight : originalHeight;
|
player.h = isCrouching ? crouchHeight : originalHeight;
|
||||||
|
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
|
||||||
let drawY = player.y;
|
|
||||||
if (isCrouching) {
|
|
||||||
drawY = player.y + (originalHeight - crouchHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
player.vy += GRAVITY;
|
player.vy += GRAVITY;
|
||||||
if (isCrouching && !player.grounded) player.vy += 2.0;
|
if (isCrouching && !player.grounded) player.vy += 2.0;
|
||||||
|
|
||||||
player.y += player.vy;
|
player.y += player.vy;
|
||||||
|
|
||||||
if (player.y + originalHeight >= GROUND_Y) {
|
if (player.y + originalHeight >= GROUND_Y) {
|
||||||
player.y = GROUND_Y - originalHeight;
|
player.y = GROUND_Y - originalHeight; player.vy = 0; player.grounded = true;
|
||||||
player.vy = 0;
|
} else { player.grounded = false; }
|
||||||
player.grounded = true;
|
|
||||||
} else {
|
|
||||||
player.grounded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextObstacles = [];
|
|
||||||
let rightmostX = 0;
|
|
||||||
|
|
||||||
|
let nextObstacles = []; let rightmostX = 0;
|
||||||
for (let obs of obstacles) {
|
for (let obs of obstacles) {
|
||||||
obs.x -= GAME_SPEED;
|
obs.x -= GAME_SPEED;
|
||||||
|
const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
|
||||||
const playerHitbox = {
|
|
||||||
x: player.x,
|
|
||||||
y: drawY,
|
|
||||||
w: player.w,
|
|
||||||
h: player.h
|
|
||||||
};
|
|
||||||
|
|
||||||
if (checkCollision(playerHitbox, obs)) {
|
if (checkCollision(playerHitbox, obs)) {
|
||||||
player.color = "darkred";
|
player.color = "darkred";
|
||||||
if (!isGameOver) {
|
if (!isGameOver) { sendChunk(); gameOver("Kollision"); }
|
||||||
sendChunk();
|
|
||||||
gameOver("Kollision (Client)");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (obs.x + obs.def.width > -100) {
|
if (obs.x + obs.def.width > -100) {
|
||||||
nextObstacles.push(obs);
|
nextObstacles.push(obs);
|
||||||
if (obs.x + obs.def.width > rightmostX) {
|
if (obs.x + obs.def.width > rightmostX) rightmostX = obs.x + obs.def.width;
|
||||||
rightmostX = obs.x + obs.def.width;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
obstacles = nextObstacles;
|
obstacles = nextObstacles;
|
||||||
|
|
||||||
if (rightmostX < GAME_WIDTH - 10 && gameConfig && gameConfig.obstacles) {
|
if (rightmostX < GAME_WIDTH - 10 && gameConfig) {
|
||||||
const gap = Math.floor(400 + rng.nextRange(0, 500));
|
const gap = Math.floor(400 + rng.nextRange(0, 500));
|
||||||
let spawnX = rightmostX + gap;
|
let spawnX = rightmostX + gap; if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH;
|
||||||
if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH;
|
|
||||||
|
|
||||||
let possibleObs = [];
|
let possibleObs = [];
|
||||||
gameConfig.obstacles.forEach(def => {
|
gameConfig.obstacles.forEach(def => {
|
||||||
if (def.id === "eraser") {
|
if (def.id === "eraser") { if (score >= 500) possibleObs.push(def); } else possibleObs.push(def);
|
||||||
if (score >= 500) possibleObs.push(def);
|
|
||||||
} else {
|
|
||||||
possibleObs.push(def);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const def = rng.pick(possibleObs);
|
const def = rng.pick(possibleObs);
|
||||||
|
|
||||||
let speech = null;
|
let speech = null;
|
||||||
if (def && def.canTalk) {
|
if (def && def.canTalk) { if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); }
|
||||||
if (rng.nextFloat() > 0.7) {
|
|
||||||
speech = rng.pick(def.speechLines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (def) {
|
if (def) {
|
||||||
const yOffset = def.yOffset || 0;
|
const yOffset = def.yOffset || 0;
|
||||||
obstacles.push({
|
obstacles.push({ x: spawnX, y: GROUND_Y - def.height - yOffset, def: def, speech: speech });
|
||||||
x: spawnX,
|
|
||||||
y: GROUND_Y - def.height - yOffset,
|
|
||||||
def: def,
|
|
||||||
speech: speech
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCollision(p, obs) {
|
function checkCollision(p, obs) {
|
||||||
const paddingX = 5;
|
const paddingX = 10; const paddingY_Top = 25; const paddingY_Bottom = 5;
|
||||||
const paddingY_Top = 5;
|
return (p.x + p.w - paddingX > obs.x + paddingX && p.x + paddingX < obs.x + obs.def.width - paddingX &&
|
||||||
const paddingY_Bottom = 5;
|
p.y + p.h - paddingY_Bottom > obs.y + paddingY_Top && p.y + paddingY_Top < obs.y + obs.def.height - paddingY_Bottom);
|
||||||
|
|
||||||
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() {
|
window.submitScore = async function() {
|
||||||
const nameInput = document.getElementById('playerNameInput');
|
const nameInput = document.getElementById('playerNameInput');
|
||||||
const name = nameInput.value;
|
const name = nameInput.value;
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
if (!name) return alert("Namen eingeben!");
|
if (!name) return alert("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'}, body: JSON.stringify({ sessionId: sessionID, name: name })
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ sessionId: sessionID, name: name })
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const claimCode = data.claimCode;
|
|
||||||
|
|
||||||
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||||
|
|
||||||
myClaims.push({
|
myClaims.push({
|
||||||
name: name,
|
name: name, score: Math.floor(score / 10), code: data.claimCode,
|
||||||
score: Math.floor(score / 10),
|
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
|
||||||
code: claimCode,
|
|
||||||
date: new Date().toLocaleString('de-DE'),
|
|
||||||
sessionId: sessionID
|
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
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';
|
document.getElementById('inputSection').style.display = 'none';
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
|
} catch (e) {}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Fehler beim Speichern!");
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadLeaderboard() {
|
async function loadLeaderboard() {
|
||||||
@@ -384,155 +241,88 @@ async function loadLeaderboard() {
|
|||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
const color = e.isMe ? "yellow" : "white";
|
const color = e.isMe ? "yellow" : "white";
|
||||||
html += `<div style="display:flex; justify-content:space-between; color:${color}; margin-bottom:5px;">
|
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>
|
<span>#${e.rank} ${e.name}</span><span>${Math.floor(e.score/10)}</span></div>`;
|
||||||
</div>`;
|
|
||||||
if(e.rank===3 && entries.length>3) html+="<div style='color:gray'>...</div>";
|
|
||||||
});
|
});
|
||||||
document.getElementById('leaderboard').innerHTML = html;
|
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 gameOver(reason) {
|
function gameOver(reason) {
|
||||||
if (isGameOver) return;
|
if (isGameOver) return;
|
||||||
isGameOver = true;
|
isGameOver = true;
|
||||||
|
|
||||||
const finalScoreVal = Math.floor(score / 10);
|
const finalScoreVal = Math.floor(score / 10);
|
||||||
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
|
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
|
||||||
if (finalScoreVal > currentHighscore) {
|
if (finalScoreVal > currentHighscore) localStorage.setItem('escape_highscore', finalScoreVal);
|
||||||
localStorage.setItem('escape_highscore', finalScoreVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
gameOverScreen.style.display = 'flex';
|
gameOverScreen.style.display = 'flex';
|
||||||
document.getElementById('finalScore').innerText = finalScoreVal;
|
document.getElementById('finalScore').innerText = finalScoreVal;
|
||||||
|
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
drawGame();
|
drawGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGame() {
|
function drawGame() {
|
||||||
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
if (bgSprite.complete && bgSprite.naturalHeight !== 0) ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
if (bgSprite.complete && bgSprite.naturalHeight !== 0) {
|
else { ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); }
|
||||||
ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
|
||||||
} 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 => {
|
obstacles.forEach(obs => {
|
||||||
const img = sprites[obs.def.id];
|
const img = sprites[obs.def.id];
|
||||||
if (img) {
|
if (img) ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height);
|
||||||
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); }
|
||||||
} 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);
|
if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech);
|
||||||
});
|
});
|
||||||
|
ctx.strokeStyle = isGameOver ? "red" : "lime"; ctx.lineWidth = 2;
|
||||||
const drawY = isCrouching ? player.y + 25 : player.y;
|
serverObstacles.forEach(srvObs => ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h));
|
||||||
const drawH = isCrouching ? 25 : 50;
|
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);
|
||||||
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
|
else { ctx.fillStyle = player.color; ctx.fillRect(player.x, drawY, player.w, drawH); }
|
||||||
ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH);
|
if (isGameOver) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); }
|
||||||
} 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) {
|
function drawSpeechBubble(x, y, text) {
|
||||||
const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
|
const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
|
||||||
ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH);
|
ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH); ctx.strokeRect(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);
|
||||||
ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center";
|
|
||||||
ctx.fillText(text, bX+bW/2, bY+20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gameLoop() {
|
function gameLoop() {
|
||||||
if (!isLoaded) return;
|
if (!isLoaded) return;
|
||||||
|
|
||||||
if (isGameRunning && !isGameOver) {
|
if (isGameRunning && !isGameOver) {
|
||||||
updateGameLogic();
|
updateGameLogic(); currentTick++; score++;
|
||||||
currentTick++;
|
const scoreEl = document.getElementById('score'); if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
|
||||||
score++;
|
|
||||||
|
|
||||||
const scoreEl = document.getElementById('score');
|
|
||||||
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
|
|
||||||
|
|
||||||
if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
|
if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
|
||||||
}
|
}
|
||||||
|
drawGame(); requestAnimationFrame(gameLoop);
|
||||||
drawGame();
|
|
||||||
requestAnimationFrame(gameLoop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initGame() {
|
async function initGame() {
|
||||||
try {
|
try {
|
||||||
const cRes = await fetch('/api/config');
|
const cRes = await fetch('/api/config'); gameConfig = await cRes.json();
|
||||||
gameConfig = await cRes.json();
|
|
||||||
|
|
||||||
await loadAssets();
|
await loadAssets();
|
||||||
|
|
||||||
await loadStartScreenLeaderboard();
|
await loadStartScreenLeaderboard();
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
const savedHighscore = localStorage.getItem('escape_highscore') || 0;
|
const savedHighscore = localStorage.getItem('escape_highscore') || 0;
|
||||||
const hsEl = document.getElementById('localHighscore');
|
const hsEl = document.getElementById('localHighscore'); if(hsEl) hsEl.innerText = savedHighscore;
|
||||||
if(hsEl) hsEl.innerText = savedHighscore;
|
|
||||||
|
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
} catch(e) {
|
} catch(e) { if(loadingText) loadingText.innerText = "Fehler!"; }
|
||||||
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');
|
|
||||||
const entries = await res.json();
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
listEl.innerHTML = "<div style='text-align:center; padding:20px; color:#666;'>Noch keine Scores.</div>";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = "";
|
|
||||||
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 += `
|
|
||||||
<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) {
|
|
||||||
console.error("Konnte Leaderboard nicht laden", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initGame();
|
initGame();
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Escape the Teacher</title>
|
<title>Escape the Teacher</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="startScreen">
|
<div id="startScreen">
|
||||||
|
|
||||||
<div class="start-left">
|
<div class="start-left">
|
||||||
<h1>ESCAPE THE<br>TEACHER</h1>
|
<h1>ESCAPE THE<br>TEACHER</h1>
|
||||||
|
|
||||||
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
|
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
|
||||||
|
|
||||||
<button id="startBtn" onclick="startGameClick()">STARTEN</button>
|
<button id="startBtn" onclick="startGameClick()">STARTEN</button>
|
||||||
@@ -34,22 +37,22 @@
|
|||||||
<p>
|
<p>
|
||||||
• Herr Müller verteilt heute Nachsitzen!<br>
|
• Herr Müller verteilt heute Nachsitzen!<br>
|
||||||
• Spring über Tische und Mülleimer.<br>
|
• Spring über Tische und Mülleimer.<br>
|
||||||
• Lass dich nicht erwischen!
|
• <strong>Neu:</strong> Ducken (Pfeil Runter) gegen fliegende Schwämme!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<div class="info-title">STEUERUNG</div>
|
<div class="info-title">STEUERUNG</div>
|
||||||
<p>
|
<p>
|
||||||
PC: <strong>Leertaste</strong>, <strong>Pfeil Hoch/Runter</strong> oder <strong>Mausklick</strong><br>
|
PC: <strong>Leertaste/Maus</strong> (Springen), <strong>Pfeil Runter</strong> (Ducken)<br>
|
||||||
Handy: <strong>Tippen</strong> (Springen) oder <strong>Wischen</strong> (Ducken)
|
Handy: <strong>Tippen</strong> (Springen), <strong>Wischen nach unten</strong> (Ducken)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legal-bar">
|
<div class="legal-bar">
|
||||||
|
<button class="legal-btn" onclick="showMyCodes()" style="color:yellow; border-color:yellow;">★ MEINE CODES</button>
|
||||||
<button class="legal-btn" onclick="openModal('impressum')">Impressum</button>
|
<button class="legal-btn" onclick="openModal('impressum')">Impressum</button>
|
||||||
<button class="legal-btn" onclick="openModal('datenschutz')">Datenschutz</button>
|
<button class="legal-btn" onclick="openModal('datenschutz')">Datenschutz</button>
|
||||||
<button class="legal-btn" onclick="showMyCodes()" style="border-color: yellow; color: yellow;">★ MEINE CODES</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,24 +79,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="modal-impressum" class="modal-overlay">
|
|
||||||
<div class="modal-content">
|
|
||||||
<button class="close-modal" onclick="closeModal()">X</button>
|
|
||||||
<h2>Impressum</h2>
|
|
||||||
<p><strong>Angaben gemäß § 5 TMG:</strong></p>
|
|
||||||
<p>Max Mustermann<br>Musterschule 1<br>12345 Musterstadt</p>
|
|
||||||
<p>Kontakt: max@schule.de</p>
|
|
||||||
<p><em>Schulprojekt. Keine kommerzielle Absicht.</em></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="modal-codes" class="modal-overlay">
|
<div id="modal-codes" class="modal-overlay">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="close-modal" onclick="closeModal()">X</button>
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
<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 zum Löschen.</p>
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-impressum" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
|
<h2>Impressum</h2>
|
||||||
|
<p><strong>Angaben gemäß § 5 TMG:</strong></p>
|
||||||
|
<p>
|
||||||
|
Sebastian Unterschütz<br>
|
||||||
|
Göltzschtalblick 16 <br>
|
||||||
|
08236 Ellefeld
|
||||||
|
</p>
|
||||||
|
<p>Kontakt: sebastian@unterschuetz.de</p>
|
||||||
|
<p><em>Dies ist ein Schulprojekt ohne kommerzielle Absicht.</em></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,80 +108,53 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="close-modal" onclick="closeModal()">X</button>
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
<h2>Datenschutz</h2>
|
<h2>Datenschutz</h2>
|
||||||
<p>Wir speichern deinen Namen und Score für die Bestenliste.</p>
|
|
||||||
<p>Dein persönlicher Highscore wird lokal auf deinem Gerät gespeichert.</p>
|
<p><strong>1. Allgemeines</strong><br>
|
||||||
|
Dies ist ein Schulprojekt. Wir speichern so wenig Daten wie möglich.</p>
|
||||||
|
|
||||||
|
<p><strong>2. Welche Daten speichern wir?</strong><br>
|
||||||
|
Wenn du einen Highscore einträgst, speichern wir auf unserem Server:
|
||||||
|
<ul>
|
||||||
|
<li>Deinen gewählten Namen</li>
|
||||||
|
<li>Deinen Punktestand</li>
|
||||||
|
<li>Einen Zeitstempel</li>
|
||||||
|
<li>Einen zufälligen "Beweis-Code"</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><strong>3. Lokale Speicherung (Dein Gerät)</strong><br>
|
||||||
|
Das Spiel nutzt den "LocalStorage" deines Browsers, um deinen persönlichen Rekord und deine gesammelten Beweis-Codes zu speichern. Diese Daten verlassen dein Gerät nicht, außer du sendest sie aktiv ab.</p>
|
||||||
|
|
||||||
|
<p><strong>4. Cookies & Tracking</strong><br>
|
||||||
|
Wir verwenden <strong>keine</strong> Tracking-Cookies, keine Analyse-Tools (wie Google Analytics) und laden keine Schriftarten von fremden Servern.</p>
|
||||||
|
|
||||||
|
<p><strong>5. Deine Rechte</strong><br>
|
||||||
|
Du kannst deine Einträge jederzeit selbstständig über das Menü "Meine Codes" vom Server löschen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="game.js"></script>
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/state.js"></script>
|
||||||
|
<script src="js/network.js"></script>
|
||||||
|
<script src="js/input.js"></script>
|
||||||
|
<script src="js/logic.js"></script>
|
||||||
|
<script src="js/render.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function openModal(id) { document.getElementById('modal-' + id).style.display = 'flex'; }
|
function openModal(id) {
|
||||||
function closeModal() { document.querySelectorAll('.modal-overlay').forEach(el => el.style.display = 'none'); }
|
document.getElementById('modal-' + id).style.display = 'flex';
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
const modals = document.querySelectorAll('.modal-overlay');
|
||||||
|
modals.forEach(el => el.style.display = 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schließen wenn man daneben klickt
|
||||||
window.onclick = function(event) {
|
window.onclick = function(event) {
|
||||||
if (event.target.classList.contains('modal-overlay')) closeModal();
|
if (event.target.classList.contains('modal-overlay')) {
|
||||||
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClaim(index, sid, code) {
|
|
||||||
if(!confirm("Willst du diesen Score wirklich unwiderruflich 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) {
|
|
||||||
alert("Fehler: Konnte nicht löschen (Vielleicht Session abgelaufen?)");
|
|
||||||
} else {
|
|
||||||
alert("Erfolgreich gelöscht!");
|
|
||||||
}
|
|
||||||
|
|
||||||
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
|
||||||
claims.splice(index, 1);
|
|
||||||
localStorage.setItem('escape_claims', JSON.stringify(claims));
|
|
||||||
|
|
||||||
showMyCodes();
|
|
||||||
|
|
||||||
} catch(e) {
|
|
||||||
alert("Verbindungsfehler");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMyCodes() {
|
|
||||||
openModal('codes');
|
|
||||||
const listEl = document.getElementById('codesList');
|
|
||||||
const claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
|
||||||
|
|
||||||
if (claims.length === 0) {
|
|
||||||
listEl.innerHTML = "Noch keine Scores eingetragen.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = "";
|
|
||||||
// Wir gehen rückwärts durch (neueste oben)
|
|
||||||
// Wir brauchen den Index 'i', um das Element auch aus dem LocalStorage zu löschen
|
|
||||||
for (let i = claims.length - 1; i >= 0; i--) {
|
|
||||||
const c = claims[i];
|
|
||||||
// Fallback, falls alte Einträge noch keine SessionID haben
|
|
||||||
const btnDisabled = c.sessionId ? "" : "disabled";
|
|
||||||
const btnStyle = c.sessionId ? "cursor:pointer; color:red;" : "color:gray;";
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div style="border-bottom:1px solid #444; padding:8px 0; display:flex; justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<span style="color:white; font-weight:bold;">${c.code}</span>
|
|
||||||
<span style="color:#ffcc00;">(${c.score} Pkt)</span><br>
|
|
||||||
<span style="color:#aaa;">${c.name} - ${c.date}</span>
|
|
||||||
</div>
|
|
||||||
<button onclick="deleteClaim(${i}, '${c.sessionId}', '${c.code}')"
|
|
||||||
style="background:transparent; border:1px solid #555; padding:5px; font-size:10px; ${btnStyle}"
|
|
||||||
${btnDisabled}>
|
|
||||||
LÖSCHEN
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
listEl.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
28
static/js/config.js
Normal file
28
static/js/config.js
Normal file
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
static/js/input.js
Normal file
45
static/js/input.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
59
static/js/logic.js
Normal file
59
static/js/logic.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
70
static/js/main.js
Normal file
70
static/js/main.js
Normal file
@@ -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();
|
||||||
92
static/js/network.js
Normal file
92
static/js/network.js
Normal file
@@ -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 = "<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) {}
|
||||||
|
}
|
||||||
107
static/js/render.js
Normal file
107
static/js/render.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
36
static/js/state.js
Normal file
36
static/js/state.js
Normal file
@@ -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');
|
||||||
@@ -35,17 +35,18 @@ body, html {
|
|||||||
box-shadow: 0 0 50px rgba(0,0,0,0.8);
|
box-shadow: 0 0 50px rgba(0,0,0,0.8);
|
||||||
border: 4px solid #444;
|
border: 4px solid #444;
|
||||||
background: #000;
|
background: #000;
|
||||||
max-width: 100%;
|
/* Größe wird von JS gesetzt, hier nur Layout-Verhalten */
|
||||||
max-height: 100%;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
image-rendering: crisp-edges;
|
image-rendering: crisp-edges;
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
@@ -64,7 +65,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
3. OVERLAYS (Start, Game Over)
|
3. OVERLAYS (Basis-Einstellungen)
|
||||||
========================================= */
|
========================================= */
|
||||||
#startScreen, #gameOverScreen {
|
#startScreen, #gameOverScreen {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -89,21 +90,21 @@ h1 {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- FIX: INPUT SECTION ZENTRIEREN --- */
|
/* Fix für Input Section (Name eingeben) */
|
||||||
#inputSection {
|
#inputSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column; /* Untereinander */
|
flex-direction: column;
|
||||||
align-items: center; /* Horizontal mittig */
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
4. START SCREEN LAYOUT
|
4. START SCREEN (Links/Rechts Layout)
|
||||||
========================================= */
|
========================================= */
|
||||||
#startScreen {
|
#startScreen {
|
||||||
flex-direction: row;
|
flex-direction: row; /* Nebeneinander */
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +126,32 @@ h1 {
|
|||||||
max-width: 35%;
|
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 {
|
.hall-of-fame-box {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border: 4px solid #ffcc00;
|
border: 4px solid #ffcc00;
|
||||||
@@ -159,7 +186,7 @@ h1 {
|
|||||||
.hof-score { color: white; font-weight: bold; }
|
.hof-score { color: white; font-weight: bold; }
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
5. BUTTONS & INPUTS
|
7. BUTTONS & INPUTS
|
||||||
========================================= */
|
========================================= */
|
||||||
button {
|
button {
|
||||||
font-family: 'Press Start 2P', cursive;
|
font-family: 'Press Start 2P', cursive;
|
||||||
@@ -190,7 +217,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
6. INFO BOXEN
|
8. INFO BOXEN
|
||||||
========================================= */
|
========================================= */
|
||||||
.info-box {
|
.info-box {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
@@ -217,20 +244,8 @@ input {
|
|||||||
text-decoration: underline;
|
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 {
|
.legal-bar {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -241,8 +256,14 @@ input {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 0;
|
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 {
|
.modal-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -275,10 +296,12 @@ input {
|
|||||||
width: 35px; height: 35px;
|
width: 35px; height: 35px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
8. PC / DESKTOP SPEZIAL
|
10. PC / DESKTOP SPEZIAL
|
||||||
========================================= */
|
========================================= */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
h1 { font-size: 48px; margin-bottom: 40px; }
|
h1 { font-size: 48px; margin-bottom: 40px; }
|
||||||
@@ -295,7 +318,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
9. MOBILE ANPASSUNG
|
11. MOBILE ANPASSUNG
|
||||||
========================================= */
|
========================================= */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
#startScreen {
|
#startScreen {
|
||||||
@@ -313,7 +336,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
10. ROTATE OVERLAY
|
12. ROTATE OVERLAY
|
||||||
========================================= */
|
========================================= */
|
||||||
#rotate-overlay {
|
#rotate-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -329,7 +352,8 @@ input {
|
|||||||
}
|
}
|
||||||
.icon { font-size: 60px; margin-bottom: 20px; }
|
.icon { font-size: 60px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* Nur anzeigen, wenn Spiel läuft UND Portrait */
|
||||||
@media screen and (orientation: portrait) {
|
@media screen and (orientation: portrait) {
|
||||||
#rotate-overlay { display: flex; }
|
body.game-active #rotate-overlay { display: flex; }
|
||||||
#game-container { display: none !important; }
|
body.game-active #game-container { display: none !important; }
|
||||||
}
|
}
|
||||||
83
types.go
Normal file
83
types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user