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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Leaderboard-Einträge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine aktiven Räume
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Score |
+ Status |
+ Powerups |
+
+
+
+
+
+ |
+ |
+
+
+ |
+
+ 2x Sprung
+ Godmode
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Name |
+ Score |
+ Player Code |
+ Proof Code |
+ Zeitstempel |
+ Status |
+ |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ | Keine Einträge gefunden |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Soll dieser Eintrag wirklich dauerhaft gelöscht werden?
+
+
+ | Name |
+ |
+
+
+ | Score |
+ |
+
+
+ | Status |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {