package main import ( "log" "math" "net/http" "time" "github.com/gorilla/websocket" ) const ( ServerTickRate = 50 * time.Millisecond BufferAhead = 60 // Puffergröße (Zukunft) SpawnXStart = 2000.0 // Spawn Abstand ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // Protokoll type WSInputMsg struct { Type string `json:"type"` Input string `json:"input"` Tick int `json:"tick"` // Optional: Client Timestamp für Ping PosY float64 `json:"y"` VelY float64 `json:"vy"` } type WSServerMsg struct { Type string `json:"type"` Obstacles []ActiveObstacle `json:"obstacles"` Platforms []ActivePlatform `json:"platforms"` ServerTick int `json:"serverTick"` Score int `json:"score"` PowerUps PowerUpState `json:"powerups"` SessionID string `json:"sessionId"` Ts int `json:"ts,omitempty"` // Für Pong CurrentSpeed float64 `json:"currentSpeed"` } func handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() // 1. Session Init sessionID := "ws-" + time.Now().Format("150405999") err = rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ "score": 0, // Startwert "is_dead": 0, "created_at": time.Now().Format("02.01.2006 15:04"), }).Err() if err != nil { log.Println("Redis Init Fehler:", err) return } rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour) // Session ID senden conn.WriteJSON(WSServerMsg{Type: "init", SessionID: sessionID}) state := SimState{ SessionID: sessionID, RNG: NewRNG(time.Now().UnixNano()), Score: 0, Ticks: 0, PosY: PlayerYBase, NextSpawnTick: 0, Chunks: loadChunksFromRedis(), } // Channel größer machen, damit bei Lag nichts blockiert inputChan := make(chan WSInputMsg, 100) closeChan := make(chan struct{}) // LESE-ROUTINE go func() { defer close(closeChan) for { var msg WSInputMsg if err := conn.ReadJSON(&msg); err != nil { return } inputChan <- msg } }() // GAME LOOP (High Performance) ticker := time.NewTicker(ServerTickRate) defer ticker.Stop() // Input State var pendingJump bool var isCrouching bool generatedHeadTick := 0 for { select { case <-closeChan: return // Client weg // WICHTIG: Wir verarbeiten Inputs hier NICHT einzeln, // sondern sammeln sie im Default-Case (siehe unten), // oder nutzen eine nicht-blockierende Schleife. // Aber für einfache Logik reicht select. // Um "Input Lag" zu verhindern, lesen wir den Channel leer: case <-ticker.C: // A. INPUTS VERARBEITEN (Alle die angekommen sind!) // Wir loopen solange durch den Channel, bis er leer ist. InputLoop: for { select { case msg := <-inputChan: if msg.Type == "input" { if msg.Input == "JUMP" { pendingJump = true } if msg.Input == "DUCK_START" { isCrouching = true } if msg.Input == "DUCK_END" { isCrouching = false } if msg.Input == "DEATH" { state.IsDead = true } } if msg.Type == "ping" { // Sofort Pong zurück (Performance wichtig!) conn.WriteJSON(WSServerMsg{Type: "pong", Ts: msg.Tick}) } if msg.Type == "sync" { diff := math.Abs(state.PosY - msg.PosY) if diff < 100.0 { state.PosY = msg.PosY state.VelY = msg.VelY } } if msg.Type == "debug" { spd := calculateSpeed(state.Ticks) conn.WriteJSON(WSServerMsg{ Type: "debug_sync", ServerTick: state.Ticks, Obstacles: state.Obstacles, Platforms: state.Platforms, Score: state.Score, CurrentSpeed: spd, }) log.Printf("🐞 Debug Snapshot an Client gesendet (Tick %d)", state.Ticks) } default: // Channel leer, weiter zur Physik break InputLoop } } // B. LIVE SIMULATION (1 Tick) // Jetzt simulieren wir genau EINEN Frame (16ms) state.Ticks++ state.Score++ // Score wächst mit der Zeit currentSpeed := calculateSpeed(state.Ticks) updatePhysics(&state, pendingJump, isCrouching, currentSpeed) pendingJump = false // Jump Trigger reset checkCollisions(&state, isCrouching, currentSpeed) if state.IsDead { // Score Persistieren rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ "score": state.Score, "is_dead": 1, }) rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour) conn.WriteJSON(WSServerMsg{Type: "dead", Score: state.Score}) return } moveWorld(&state, currentSpeed) // C. STREAMING (Zukunft) // Wir generieren nur, wenn der Puffer zur Neige geht targetTick := state.Ticks + BufferAhead var newObs []ActiveObstacle var newPlats []ActivePlatform // Um CPU zu sparen, generieren wir max 10 Ticks pro Frame nach loops := 0 for generatedHeadTick < targetTick && loops < 10 { generatedHeadTick++ loops++ o, p := generateFutureObjects(&state, generatedHeadTick, currentSpeed) if len(o) > 0 { newObs = append(newObs, o...) state.Obstacles = append(state.Obstacles, o...) } if len(p) > 0 { newPlats = append(newPlats, p...) state.Platforms = append(state.Platforms, p...) } } // D. SENDEN (Effizienz) // Nur senden wenn Daten da sind ODER alle 15 Frames (Heartbeat/Score Sync) if len(newObs) > 0 || len(newPlats) > 0 || state.Ticks%15 == 0 { msg := WSServerMsg{ Type: "chunk", ServerTick: state.Ticks, Score: state.Score, Obstacles: newObs, Platforms: newPlats, PowerUps: PowerUpState{ GodLives: state.GodLives, HasBat: state.HasBat, BootTicks: state.BootTicks, }, } conn.WriteJSON(msg) } } } } // Hilfsfunktion: Generiert Objekte für EINEN Tick in der Zukunft func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstacle, []ActivePlatform) { var createdObs []ActiveObstacle var createdPlats []ActivePlatform // Initialisierung beim ersten Lauf if s.NextSpawnTick == 0 { s.NextSpawnTick = tick + 50 } // Ist es Zeit für etwas Neues? if tick >= s.NextSpawnTick { spawnX := SpawnXStart // --- ENTSCHEIDUNG: CHUNK vs RANDOM --- // Wir nutzen die globalen Chunks (da Read-Only während des Spiels, ist Zugriff sicher) chunkCount := len(defaultConfig.Chunks) if chunkCount > 0 && s.RNG.NextFloat() > 0.8 { // ================================================= // OPTION A: CHUNK SPAWNING // ================================================= idx := int(s.RNG.NextRange(0, float64(chunkCount))) chunk := defaultConfig.Chunks[idx] // 1. Plattformen übernehmen for _, p := range chunk.Platforms { createdPlats = append(createdPlats, ActivePlatform{ X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height, }) } // 2. Hindernisse übernehmen & Speech berechnen for _, o := range chunk.Obstacles { // Speech-Logik: Wir müssen die Original-Def finden, um zu wissen, ob er sprechen kann speech := "" for _, def := range defaultConfig.Obstacles { if def.ID == o.ID { // Wenn gefunden, würfeln wir if def.CanTalk && len(def.SpeechLines) > 0 { if s.RNG.NextFloat() > 0.7 { // 30% Wahrscheinlichkeit sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines)))) speech = def.SpeechLines[sIdx] } } break // Def gefunden, Loop abbrechen } } createdObs = append(createdObs, ActiveObstacle{ ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height, Speech: speech, // <--- HIER wird der Text gesetzt }) } // Timer setzen (Länge des Chunks) width := float64(chunk.TotalWidth) if width == 0 { width = 2000.0 } s.NextSpawnTick = tick + int(width/speed) } else { // ================================================= // OPTION B: RANDOM SPAWNING // ================================================= // Lücke berechnen gap := 400 + int(s.RNG.NextRange(0, 500)) s.NextSpawnTick = tick + int(float64(gap)/speed) // Pool bilden (Boss Phase?) defs := defaultConfig.Obstacles if len(defs) > 0 { isBoss := (tick % 1500) > 1200 var pool []ObstacleDef for _, d := range defs { if isBoss { if d.ID == "principal" || d.ID == "trashcan" { pool = append(pool, d) } } else { if d.ID != "principal" { pool = append(pool, d) } } } // Objekt auswählen def := s.RNG.PickDef(pool) if def != nil { // Powerup Rarity (90% Chance, dass es NICHT spawnt) if def.Type == "powerup" && s.RNG.NextFloat() > 0.1 { def = nil } if def != nil { // Speech Logik speech := "" if def.CanTalk && len(def.SpeechLines) > 0 { if s.RNG.NextFloat() > 0.7 { sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines)))) speech = def.SpeechLines[sIdx] } } // Y-Position berechnen (Boden - Höhe - Offset) spawnY := GroundY - def.Height - def.YOffset createdObs = append(createdObs, ActiveObstacle{ ID: def.ID, Type: def.Type, X: spawnX, Y: spawnY, Width: def.Width, Height: def.Height, Speech: speech, // <--- HIER wird der Text gesetzt }) } } } } } return createdObs, createdPlats }