From aff505773a50a0d0581659f3b5c8ea44d49d06aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 22 Mar 2026 10:44:58 +0100 Subject: [PATCH] fix game --- .github/workflows/deploy.yaml | 7 + cmd/client/game_render.go | 16 ++ cmd/client/prediction.go | 5 +- cmd/client/web/admin.html | 435 ++++++++++++++++++++++++++++++++++ cmd/server/admin.go | 135 +++++++++++ cmd/server/gin_server.go | 3 + k8s/app.yaml | 10 + pkg/config/config.go | 16 +- pkg/game/data.go | 1 + pkg/physics/physics.go | 18 +- pkg/server/leaderboard.go | 46 ++++ pkg/server/room.go | 17 +- 12 files changed, 693 insertions(+), 16 deletions(-) create mode 100644 cmd/client/web/admin.html create mode 100644 cmd/server/admin.go diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index dc35df6..f4e506d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -165,6 +165,13 @@ jobs: sed -i "s|\${TARGET_NS}|${{ env.TARGET_NS }}|g" k8s/ingress.yaml sed -i "s|\${IMAGE_NAME}|${{ env.DEPLOY_IMAGE }}|g" k8s/app.yaml + # Admin-Credentials Secret anlegen/aktualisieren (aus Gitea Secret) + kubectl create secret generic admin-credentials \ + --from-literal=username="${{ secrets.ADMIN_USER }}" \ + --from-literal=password="${{ secrets.ADMIN_PASSWORD }}" \ + --namespace=${{ env.TARGET_NS }} \ + --dry-run=client -o yaml | kubectl apply -f - + # Anwenden echo "Deploying to Namespace: ${{ env.TARGET_NS }} (Image: ${{ env.DEPLOY_IMAGE }})" kubectl apply -f k8s/compress-middleware.yaml -n ${{ env.TARGET_NS }} diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index d5a3106..84756bd 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -497,6 +497,22 @@ func (g *Game) DrawGame(screen *ebiten.Image) { msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft) text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255}) } else if g.gameState.Status == "RUNNING" { + // Danger-Overlay: Ab DifficultyFactor > 0.5 rötlicher Bildschirmrand + g.stateMutex.Lock() + df := g.gameState.DifficultyFactor + g.stateMutex.Unlock() + if df > 0.5 { + // Alpha von 0 (bei df=0.5) bis 60 (bei df=1.0) + dangerAlpha := uint8((df - 0.5) * 2.0 * 60) + canvasWf, canvasHf := float32(canvasW), float32(canvasH) + borderW := float32(8) + col := color.RGBA{200, 0, 0, dangerAlpha} + vector.DrawFilledRect(screen, 0, 0, canvasWf, borderW, col, false) + vector.DrawFilledRect(screen, 0, canvasHf-borderW, canvasWf, borderW, col, false) + vector.DrawFilledRect(screen, 0, 0, borderW, canvasHf, col, false) + vector.DrawFilledRect(screen, canvasWf-borderW, 0, borderW, canvasHf, col, false) + } + // Score/Distance Anzeige mit grauem Hintergrund (oben rechts) dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0) scoreStr := fmt.Sprintf("Score: %d", myScore) diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go index 608db91..3a907f8 100644 --- a/cmd/client/prediction.go +++ b/cmd/client/prediction.go @@ -45,10 +45,11 @@ func (g *Game) ApplyInput(input InputState) { ActiveChunks: g.gameState.WorldChunks, MovingPlatforms: g.gameState.MovingPlatforms, } + difficultyFactor := g.gameState.DifficultyFactor g.stateMutex.Unlock() - // Gemeinsame Physik anwenden (1:1 wie Server) - physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, collisionChecker, physics.DefaultPlayerConstants()) + // Gemeinsame Physik anwenden (1:1 wie Server, inkl. Schwierigkeits-Skalierung) + physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, difficultyFactor, collisionChecker, physics.DefaultPlayerConstants()) // Ergebnis zurückschreiben g.predictedX = state.X diff --git a/cmd/client/web/admin.html b/cmd/client/web/admin.html new file mode 100644 index 0000000..db9ac7b --- /dev/null +++ b/cmd/client/web/admin.html @@ -0,0 +1,435 @@ + + + + + + Admin Panel – Escape From Teacher + + + + + + + + + +
+ + +
+
+
+
+
Aktive Räume
+
+
+
+
+
+
Spieler Online
+
+
+
+
+
+
Leaderboard-Einträge
+
+
+
+
+
+
Ungültige Einträge
+
+
+
+ + +
+ + +
+ + +
+
+
+ Laufende Räume + +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+ Leaderboard-Einträge + +
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + +
#NameScorePlayer CodeProof CodeZeitstempelStatus
+
+
+
+
+ +
+ + + + + +
+
+
+
+ + + + + diff --git a/cmd/server/admin.go b/cmd/server/admin.go new file mode 100644 index 0000000..4294bcb --- /dev/null +++ b/cmd/server/admin.go @@ -0,0 +1,135 @@ +package main + +import ( + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/server" +) + +// adminRoomPlayer ist eine vereinfachte Spieler-Ansicht für das Admin-Panel +type adminRoomPlayer struct { + Name string `json:"name"` + Score int `json:"score"` + IsAlive bool `json:"is_alive"` + IsSpectator bool `json:"is_spectator"` + X float64 `json:"x"` + HasDoubleJump bool `json:"has_double_jump"` + HasGodMode bool `json:"has_godmode"` +} + +// adminRoom ist eine vereinfachte Raum-Ansicht für das Admin-Panel +type adminRoom struct { + ID string `json:"id"` + Status string `json:"status"` + PlayerCount int `json:"player_count"` + AliveCount int `json:"alive_count"` + ScrollX float64 `json:"scroll_x"` + CurrentSpeed float64 `json:"current_speed"` + DifficultyFactor float64 `json:"difficulty_factor"` + ElapsedSeconds int `json:"elapsed_seconds"` + HostID string `json:"host_id"` + Players []adminRoomPlayer `json:"players"` +} + +// RegisterAdminRoutes registriert alle Admin-Routen mit BasicAuth +func RegisterAdminRoutes(r *gin.Engine) { + user := os.Getenv("ADMIN_USER") + if user == "" { + user = "admin" + } + password := os.Getenv("ADMIN_PASSWORD") + if password == "" { + password = "changeme" + } + + admin := r.Group("/admin", gin.BasicAuth(gin.Accounts{user: password})) + + // Admin Panel HTML + admin.GET("", func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + http.ServeFile(c.Writer, c.Request, "./cmd/client/web/admin.html") + }) + + // --- API: Leaderboard --- + + admin.GET("/api/leaderboard", func(c *gin.Context) { + if server.GlobalLeaderboard == nil { + c.JSON(503, gin.H{"error": "Leaderboard nicht verfügbar"}) + return + } + entries := server.GlobalLeaderboard.GetAll() + c.JSON(200, entries) + }) + + admin.DELETE("/api/leaderboard/:key", func(c *gin.Context) { + key := c.Param("key") + if key == "" { + c.JSON(400, gin.H{"error": "Kein Key angegeben"}) + return + } + if server.GlobalLeaderboard == nil { + c.JSON(503, gin.H{"error": "Leaderboard nicht verfügbar"}) + return + } + if err := server.GlobalLeaderboard.DeleteEntry(key); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"ok": true, "deleted": key}) + }) + + // --- API: Rooms --- + + admin.GET("/api/rooms", func(c *gin.Context) { + mu.RLock() + defer mu.RUnlock() + + result := make([]adminRoom, 0, len(rooms)) + for _, room := range rooms { + room.Mutex.RLock() + + elapsed := 0 + if room.Status == "RUNNING" || room.Status == "GAMEOVER" { + elapsed = int(time.Since(room.GameStartTime).Seconds()) + } + + players := make([]adminRoomPlayer, 0, len(room.Players)) + aliveCount := 0 + for _, p := range room.Players { + if p.IsAlive && !p.IsSpectator { + aliveCount++ + } + players = append(players, adminRoomPlayer{ + Name: p.Name, + Score: p.Score, + IsAlive: p.IsAlive, + IsSpectator: p.IsSpectator, + X: p.X, + HasDoubleJump: p.HasDoubleJump, + HasGodMode: p.HasGodMode, + }) + } + + result = append(result, adminRoom{ + ID: room.ID, + Status: room.Status, + PlayerCount: len(room.Players), + AliveCount: aliveCount, + ScrollX: room.GlobalScrollX, + CurrentSpeed: room.CurrentSpeed, + DifficultyFactor: room.DifficultyFactor, + ElapsedSeconds: elapsed, + HostID: room.HostID, + Players: players, + }) + + room.Mutex.RUnlock() + } + + c.JSON(200, result) + }) +} diff --git a/cmd/server/gin_server.go b/cmd/server/gin_server.go index 4a91af9..59975c0 100644 --- a/cmd/server/gin_server.go +++ b/cmd/server/gin_server.go @@ -88,6 +88,9 @@ func SetupGinServer(ec *nats.EncodedConn, port string) *gin.Engine { r.StaticFile("/main.wasm", "./cmd/client/web/main.wasm") r.StaticFile("/background.jpg", "./cmd/client/web/background.jpg") + // Admin Panel + RegisterAdminRoutes(r) + // 404 Handler r.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{ diff --git a/k8s/app.yaml b/k8s/app.yaml index 8907477..431d27c 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -27,6 +27,16 @@ spec: value: "redis:6379" - name: NATS_URL value: "nats://nats:4222" + - name: ADMIN_USER + valueFrom: + secretKeyRef: + name: admin-credentials + key: username + - name: ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: admin-credentials + key: password - name: TOTAL_REPLICAS value: "2" - name: POD_NAME diff --git a/pkg/config/config.go b/pkg/config/config.go index 0124058..bcc24af 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,12 +15,16 @@ const ( TileSize = 64 // Player Movement (bei 20 TPS) - RunSpeed = 21.0 // Basis-Scroll-Geschwindigkeit - PlayerSpeed = 33.0 // Links/Rechts Bewegung relativ zu Scroll (war 11.0 * 3) - JumpVelocity = 24.0 // Sprunghöhe (reduziert für besseres Gefühl) - FastFall = 45.0 // Schnell-Fall nach unten - WallSlideMax = 9.0 // Maximale Rutsch-Geschwindigkeit an Wand - WallClimbSpeed = 15.0 // Kletter-Geschwindigkeit + RunSpeed = 21.0 // Basis-Scroll-Geschwindigkeit + PlayerSpeed = 33.0 // Links/Rechts Bewegung relativ zu Scroll (war 11.0 * 3) + AirControlFactor = 0.4 // In der Luft: nur 40% der normalen Horizontalkontrolle (Basis) + AirControlMin = 0.15 // Minimale Air-Control bei maximaler Schwierigkeit + JumpVelocity = 24.0 // Sprunghöhe (reduziert für besseres Gefühl) + FastFall = 45.0 // Schnell-Fall nach unten + WallSlideMax = 9.0 // Maximale Rutsch-Geschwindigkeit an Wand + WallClimbSpeed = 15.0 // Kletter-Geschwindigkeit + GravityMax = 2.8 // Maximale Gravitation (bei DifficultyFactor=1.0) + MaxDifficultySeconds = 180.0 // Sekunden bis maximale Schwierigkeit erreicht ist // Gameplay StartTime = 5 // Sekunden Countdown diff --git a/pkg/game/data.go b/pkg/game/data.go index 37e1c66..3e7ec03 100644 --- a/pkg/game/data.go +++ b/pkg/game/data.go @@ -123,6 +123,7 @@ type GameState struct { MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen Sequence uint32 `json:"sequence"` // Sequenznummer für Out-of-Order-Erkennung CurrentSpeed float64 `json:"current_speed"` // Aktuelle Scroll-Geschwindigkeit (für Client-Prediction) + DifficultyFactor float64 `json:"difficulty_factor"` // 0.0 (Anfang) bis 1.0 (Maximum) – skaliert Gravitation & Air-Control } // MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform diff --git a/pkg/physics/physics.go b/pkg/physics/physics.go index 1c3a08e..8bec23d 100644 --- a/pkg/physics/physics.go +++ b/pkg/physics/physics.go @@ -56,11 +56,23 @@ func ApplyPhysics( state *PlayerPhysicsState, input PhysicsInput, currentSpeed float64, + difficultyFactor float64, collisionChecker CollisionChecker, playerConst PlayerConstants, ) { + // Schwierigkeits-skalierte Parameter + // Air Control sinkt von AirControlFactor (0.4) auf AirControlMin (0.15) + effectiveAirControl := config.AirControlFactor - (config.AirControlFactor-config.AirControlMin)*difficultyFactor + // Gravitation steigt von Gravity (1.5) auf GravityMax (2.8) + effectiveGravity := config.Gravity + (config.GravityMax-config.Gravity)*difficultyFactor + // --- HORIZONTALE BEWEGUNG MIT KOLLISION --- - playerMovement := input.InputX * config.PlayerSpeed + // In der Luft: reduzierte Horizontalkontrolle (skaliert mit Schwierigkeit) + airControl := 1.0 + if !state.OnGround && !state.OnWall { + airControl = effectiveAirControl + } + playerMovement := input.InputX * config.PlayerSpeed * airControl speed := currentSpeed + playerMovement nextX := state.X + speed @@ -96,8 +108,8 @@ func ApplyPhysics( state.VY = -config.WallClimbSpeed } } else { - // Normal: Volle Gravität - state.VY += config.Gravity + // Normal: Schwierigkeit-skalierte Gravität + state.VY += effectiveGravity if state.VY > config.MaxFall { state.VY = config.MaxFall } diff --git a/pkg/server/leaderboard.go b/pkg/server/leaderboard.go index adb0b1c..56df12a 100644 --- a/pkg/server/leaderboard.go +++ b/pkg/server/leaderboard.go @@ -106,6 +106,52 @@ func (lb *Leaderboard) AddScore(name, code string, score int) (bool, string) { return true, proofCode } +// AdminLeaderboardEntry ist ein LeaderboardEntry mit zusätzlichem Key und Validierungsstatus +type AdminLeaderboardEntry struct { + game.LeaderboardEntry + Key string `json:"key"` + Valid bool `json:"valid"` +} + +// GetAll gibt alle Leaderboard-Einträge zurück (für Admin-Panel) +func (lb *Leaderboard) GetAll() []AdminLeaderboardEntry { + raw, err := lb.rdb.HGetAll(lb.ctx, "leaderboard:entries").Result() + if err != nil { + log.Printf("⚠️ Fehler beim Abrufen aller Einträge: %v", err) + return nil + } + + entries := make([]AdminLeaderboardEntry, 0, len(raw)) + for key, dataStr := range raw { + var e game.LeaderboardEntry + if err := json.Unmarshal([]byte(dataStr), &e); err != nil { + continue + } + expected := GenerateProofCode(e.PlayerCode, e.Score, e.Timestamp) + entries = append(entries, AdminLeaderboardEntry{ + LeaderboardEntry: e, + Key: key, + Valid: e.ProofCode == expected, + }) + } + + // Absteigend nach Score sortieren + for i := 0; i < len(entries); i++ { + for j := i + 1; j < len(entries); j++ { + if entries[j].Score > entries[i].Score { + entries[i], entries[j] = entries[j], entries[i] + } + } + } + return entries +} + +// DeleteEntry löscht einen Eintrag aus Leaderboard (Hash + Sorted Set) +func (lb *Leaderboard) DeleteEntry(key string) error { + lb.rdb.ZRem(lb.ctx, leaderboardKey, key) + return lb.rdb.HDel(lb.ctx, "leaderboard:entries", key).Err() +} + func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry { // Hole Top 10 (höchste Scores zuerst) uniqueKeys, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result() diff --git a/pkg/server/room.go b/pkg/server/room.go index 7b29073..d65b719 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -79,6 +79,7 @@ type Room struct { CollectedPowerups map[string]bool // Key: "chunkID_objectIndex" ScoreAccum float64 // Akkumulator für Distanz-Score CurrentSpeed float64 // Aktuelle Geschwindigkeit (steigt mit der Zeit) + DifficultyFactor float64 // 0.0 (Start) bis 1.0 (Maximum) – skaliert Schwierigkeit GameStartTime time.Time // Wann das Spiel gestartet wurde // Chunk-Pool für fairen Random-Spawn @@ -368,12 +369,17 @@ func (r *Room) Update() { r.CurrentSpeed = config.RunSpeed } } else if r.Status == "RUNNING" { - // Geschwindigkeit erhöhen: +0.5 pro 10 Sekunden (max +5.0 nach 100 Sekunden) elapsed := time.Since(r.GameStartTime).Seconds() - speedIncrease := (elapsed / 10.0) * 0.5 - if speedIncrease > 5.0 { - speedIncrease = 5.0 + + // DifficultyFactor: 0.0 am Start, 1.0 nach MaxDifficultySeconds (180s) + r.DifficultyFactor = elapsed / config.MaxDifficultySeconds + if r.DifficultyFactor > 1.0 { + r.DifficultyFactor = 1.0 } + + // Geschwindigkeit: quadratische Kurve → am Anfang langsam, dann immer schneller + // Bei MaxDifficultySeconds: +18 auf RunSpeed (39 total) + speedIncrease := r.DifficultyFactor * r.DifficultyFactor * 18.0 r.CurrentSpeed = config.RunSpeed + speedIncrease r.GlobalScrollX += r.CurrentSpeed @@ -429,7 +435,7 @@ func (r *Room) Update() { } // Gemeinsame Physik anwenden (1:1 wie Client!) - physics.ApplyPhysics(&state, physicsInput, r.CurrentSpeed, collisionChecker, physics.DefaultPlayerConstants()) + physics.ApplyPhysics(&state, physicsInput, r.CurrentSpeed, r.DifficultyFactor, collisionChecker, physics.DefaultPlayerConstants()) // Ergebnis zurückschreiben p.X = state.X @@ -829,6 +835,7 @@ func (r *Room) Broadcast() { MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)), Sequence: r.sequence, CurrentSpeed: r.CurrentSpeed, + DifficultyFactor: r.DifficultyFactor, } for id, p := range r.Players {