diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index ae85a43..7eded9d 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -115,6 +115,10 @@ func (g *Game) UpdateGame() { func (g *Game) handleTouchInput() { touches := ebiten.TouchIDs() + // WICHTIG: Joystick-Base-Position wird jetzt beim Rendering gesetzt (DrawGame) + // um sicherzustellen dass die gleiche Canvas-Höhe verwendet wird! + // Wir verwenden hier nur die gecachte Position. + // Reset, wenn keine Finger mehr auf dem Display sind if len(touches) == 0 { g.joyActive = false @@ -228,6 +232,10 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } g.stateMutex.Unlock() + // Canvas-Größe und Scale-Faktor + canvasW, canvasH := screen.Size() + viewScale := GetScaleFromHeight(canvasH) + // 1. Hintergrund (wechselt alle 5000 Punkte) backgroundID := "background" if myScore >= 10000 { @@ -240,8 +248,6 @@ func (g *Game) DrawGame(screen *ebiten.Image) { if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil { op := &ebiten.DrawImageOptions{} - // Tatsächliche Canvas-Größe verwenden (nicht nur ScreenWidth/Height) - canvasW, canvasH := screen.Size() bgW, bgH := bgImg.Size() // Skalierung berechnen, um Canvas komplett zu füllen @@ -265,12 +271,17 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } // Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera) - g.RenderGround(screen, g.camX) + // Mit viewScale multiplizieren damit auf Mobile mehr Welt sichtbar ist + effectiveCamX := g.camX / viewScale + g.RenderGround(screen, effectiveCamX) // State Locken für Datenzugriff g.stateMutex.Lock() defer g.stateMutex.Unlock() + // Screen-Höhe für Y-Transformation (canvasH bereits oben definiert) + // _, canvasH = screen.Size() // nicht nötig, bereits definiert + // 2. Chunks (Welt-Objekte) for _, activeChunk := range g.gameState.WorldChunks { chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID] @@ -303,19 +314,19 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } } - // Asset zeichnen - g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y) + // Asset zeichnen (mit Welt-zu-Screen-Y-Transformation) + g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, canvasH)) } } // 2.5 Bewegende Plattformen (von Server synchronisiert) for _, mp := range g.gameState.MovingPlatforms { - g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y) + g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, canvasH)) } // 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - NUR IM DEBUG-MODUS if g.showDebug { - vector.StrokeRect(screen, float32(-g.camX), float32(540), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false) + vector.StrokeRect(screen, float32(-g.camX), float32(WorldToScreenYWithHeight(540, canvasH)), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false) } // 3. Spieler @@ -351,31 +362,35 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } } - g.DrawAsset(screen, sprite, posX, posY) + // Konvertiere Welt-Y zu Screen-Y für korrektes Rendering + _, canvasH := screen.Size() + screenY := WorldToScreenYWithHeight(posY, canvasH) + + g.DrawAsset(screen, sprite, posX, screenY) // Name Tag name := p.Name if name == "" { name = id } - text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(posY-25), ColText) + text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(screenY-25), ColText) // HITBOX VISUALISIERUNG (NUR IM DEBUG-MODUS) if g.showDebug { if def, ok := g.world.Manifest.Assets["player"]; ok { - // Spieler-Hitbox (ROT) + // Spieler-Hitbox (ROT) - mit Screen-Y-Transformation hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX) - hy := float32(posY + def.DrawOffY + def.Hitbox.OffsetY) + hy := float32(screenY + def.DrawOffY + def.Hitbox.OffsetY) vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 3, color.RGBA{255, 0, 0, 255}, false) - // Spieler-Position als Punkt (GELB) - vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(posY), 5, color.RGBA{255, 255, 0, 255}, false) + // Spieler-Position als Punkt (GELB) - mit Screen-Y-Transformation + vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false) } } } // 4. UI Status (Canvas-relativ) - canvasW, canvasH := screen.Size() + canvasW, canvasH = screen.Size() if g.gameState.Status == "COUNTDOWN" { msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft) @@ -430,25 +445,37 @@ func (g *Game) DrawGame(screen *ebiten.Image) { // 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde) if !g.keyboardUsed { - canvasW, _ := screen.Size() + canvasW, canvasH := screen.Size() + + // Cache Canvas-Höhe für Touch-Input (wird in UpdateGame/handleTouchInput verwendet) + g.lastCanvasHeight = canvasH + + // A) Joystick Base (unten links, über dem Boden positioniert) + // WICHTIG: Verwende die gleiche Berechnung wie in handleTouchInput()! + g.joyBaseX = 150.0 + floorY := GetFloorYFromHeight(canvasH) + g.joyBaseY = floorY - 50.0 // 50px über dem Boden (sichtbar über der Erde) + + // Wenn Joystick nicht aktiv, Stick = Base + if !g.joyActive { + g.joyStickX = g.joyBaseX + g.joyStickY = g.joyBaseY + } - // A) Joystick Base (unten links, relativ zu Viewport) - joyX := 150.0 - joyY := float64(ScreenHeight) - 150.0 // ScreenHeight statt canvasH! baseCol := color.RGBA{80, 80, 80, 50} - vector.DrawFilledCircle(screen, float32(joyX), float32(joyY), 60, baseCol, false) - vector.StrokeCircle(screen, float32(joyX), float32(joyY), 60, 2, color.RGBA{100, 100, 100, 100}, false) + vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false) + vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false) - // B) Joystick Knob (relativ zu Base, nicht zu Canvas) + // B) Joystick Knob (relativ zu Base) knobCol := color.RGBA{100, 100, 100, 80} if g.joyActive { knobCol = color.RGBA{100, 255, 100, 120} } vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false) - // C) Jump Button (unten rechts, relativ zu Viewport) + // C) Jump Button (unten rechts, über dem Boden positioniert) jumpX := float32(canvasW) - 150 - jumpY := float32(ScreenHeight) - 150 // ScreenHeight statt canvasH! + jumpY := float32(floorY) - 50 // 50px über dem Boden vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false) vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false) text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150}) @@ -465,13 +492,17 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl return } - // 2. Screen Position berechnen (Welt - Kamera) - screenX := worldX - g.camX + // Canvas-Größe und View-Scale + canvasW, canvasH := screen.Size() + viewScale := GetScaleFromHeight(canvasH) + + // 2. Screen Position berechnen (Welt - Kamera, mit Scale) + effectiveCamX := g.camX / viewScale + screenX := (worldX - effectiveCamX) * viewScale screenY := worldY // Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden) // Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px) - canvasW, _ := screen.Size() if screenX < -800 || screenX > float64(canvasW)+800 { return } @@ -485,13 +516,14 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl // Filter für bessere Skalierung (besonders bei großen Sprites) op.Filter = ebiten.FilterLinear - // Skalieren - op.GeoM.Scale(def.Scale, def.Scale) + // Skalieren: Asset-Scale * View-Scale (auf Mobile kleiner) + finalScale := def.Scale * viewScale + op.GeoM.Scale(finalScale, finalScale) - // Positionieren: ScreenPos + DrawOffset + // Positionieren: ScreenPos + DrawOffset (auch skaliert) op.GeoM.Translate( - screenX+def.DrawOffX, - screenY+def.DrawOffY, + screenX+(def.DrawOffX*viewScale), + screenY+(def.DrawOffY*viewScale), ) // Farbe anwenden (nur wenn explizit gesetzt) diff --git a/cmd/client/ground_system.go b/cmd/client/ground_system.go index 5f8b932..d66e0e6 100644 --- a/cmd/client/ground_system.go +++ b/cmd/client/ground_system.go @@ -58,27 +58,27 @@ func GenerateGroundTile(tileIdx int) GroundTile { Stones: make([]Stone, 0), } - // Zufällige Dirt-Patches generieren (20-30 pro Tile, über die ganze Höhe) - numDirt := 20 + rng.Intn(10) - dirtHeight := 5000.0 // Gesamte Dirt-Höhe bis tief in die Erde + // Zufällige Dirt-Patches generieren (15-25 pro Tile, kompakt) + numDirt := 15 + rng.Intn(10) + maxDirtHeight := 100.0 // Kompakte Erde-Tiefe (100px) for i := 0; i < numDirt; i++ { darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne tile.DirtVariants = append(tile.DirtVariants, DirtPatch{ OffsetX: rng.Float64() * 128, - OffsetY: rng.Float64()*dirtHeight + 20, // Über die ganze Dirt-Schicht verteilt + OffsetY: rng.Float64()*maxDirtHeight + 20, // Nur im sichtbaren Bereich Width: 10 + rng.Float64()*30, Height: 10 + rng.Float64()*25, Color: color.RGBA{darkness, darkness - 10, darkness - 20, 255}, }) } - // Steine IN der Erde generieren (30-50 pro Tile, nur eckig, tief verteilt) - numStones := 30 + rng.Intn(20) + // Steine IN der Erde generieren (20-30 pro Tile, nur eckig, kompakt) + numStones := 20 + rng.Intn(10) for i := 0; i < numStones; i++ { tile.Stones = append(tile.Stones, Stone{ X: rng.Float64() * 128, - Y: rng.Float64()*dirtHeight + 20, // Tief in der Erde verteilt - Size: 4 + rng.Float64()*8, // Verschiedene Größen + Y: rng.Float64()*maxDirtHeight + 20, // Nur im sichtbaren Bereich + Size: 4 + rng.Float64()*8, // Verschiedene Größen Color: color.RGBA{100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 255}, Shape: 1, // Nur eckig (keine runden Steine mehr) }) @@ -92,18 +92,28 @@ func GenerateGroundTile(tileIdx int) GroundTile { // RenderGround rendert den Boden mit Bewegung func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { // Tatsächliche Canvas-Größe verwenden - canvasW, _ := screen.Size() + canvasW, canvasH := screen.Size() - // Boden bleibt an fester Position (RefFloorY) - wichtig für Spielphysik! - // Erweitere Boden nach unten weit über Canvas-Rand hinaus (5000 Pixel tief) - floorY := float32(RefFloorY) - floorH := float32(5000) // Tief in die Erde + // Gameplay-Boden-Position (wo Spieler laufen) - mit echter Canvas-Höhe! + gameFloorY := float32(GetFloorYFromHeight(canvasH)) + + // Erde-Tiefe - kompakt (100px) damit Gameplay optimal Platz hat + maxDirtDepth := float32(100.0) + + // Berechne wie viel Erde sichtbar sein soll (bis zum Bildschirmrand) + remainingSpace := float32(canvasH) - gameFloorY + visibleDirtHeight := maxDirtDepth + if remainingSpace < maxDirtDepth { + visibleDirtHeight = remainingSpace + } // 1. Basis Gras-Schicht (volle Canvas-Breite, nur dünne Grasnarbe) - vector.DrawFilledRect(screen, 0, floorY, float32(canvasW), 20, ColGrass, false) + vector.DrawFilledRect(screen, 0, gameFloorY, float32(canvasW), 20, ColGrass, false) - // 2. Dirt-Schicht (Basis, volle Canvas-Breite, tief nach unten) - vector.DrawFilledRect(screen, 0, floorY+20, float32(canvasW), floorH-20, ColDirt, false) + // 2. Dirt-Schicht (Basis, volle Canvas-Breite, nur sichtbarer Bereich) + if visibleDirtHeight > 20 { + vector.DrawFilledRect(screen, 0, gameFloorY+20, float32(canvasW), visibleDirtHeight-20, ColDirt, false) + } // 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera) // Berechne welche Tiles sichtbar sind (basierend auf Canvas-Breite) @@ -119,7 +129,7 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { for _, dirt := range tile.DirtVariants { worldX := tile.X + dirt.OffsetX screenX := float32(worldX - cameraX) - screenY := float32(RefFloorY) + float32(dirt.OffsetY) + screenY := gameFloorY + float32(dirt.OffsetY) // Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden) if screenX+float32(dirt.Width) > 0 && screenX < float32(canvasW) { @@ -127,11 +137,11 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { } } - // Steine rendern (auf dem Gras) + // Steine rendern (in der Erde) for _, stone := range tile.Stones { worldX := tile.X + stone.X screenX := float32(worldX - cameraX) - screenY := float32(RefFloorY) + float32(stone.Y) + screenY := gameFloorY + float32(stone.Y) // Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden) if screenX > -20 && screenX < float32(canvasW)+20 { diff --git a/cmd/client/main.go b/cmd/client/main.go index f47b9a4..4f89d44 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -30,7 +30,8 @@ const ( StateLobby = 1 StateGame = 2 StateLeaderboard = 3 - RefFloorY = 540 + RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich) + RefFloorYMobile = 270 // Nicht mehr verwendet ) var ( @@ -122,6 +123,7 @@ type Game struct { joyTouchID ebiten.TouchID btnJumpActive bool keyboardUsed bool // Wurde Tastatur benutzt? + lastCanvasHeight int // Cache der Canvas-Höhe für Touch-Input // Debug Stats showDebug bool // Debug-Overlay anzeigen (F3 zum Umschalten) @@ -632,12 +634,104 @@ func (g *Game) DrawLobby(screen *ebiten.Image) { } func (g *Game) Layout(w, h int) (int, int) { - // Nutze die GESAMTE Bildschirmfläche ohne Einschränkungen + // Nutze die echte Window-Größe (Mobile: ~360px Höhe, Desktop: 720px+ Höhe) + // Das erlaubt dynamische Anpassung an verschiedene Bildschirmgrößen return w, h } // --- HELPER --- +// GetFloorY gibt die Y-Position des Bodens basierend auf der aktuellen Bildschirmhöhe zurück +// WICHTIG: Kann nicht direkt aufgerufen werden, braucht Screen-Höhe als Parameter! +func GetFloorYFromHeight(screenHeight int) float64 { + h := screenHeight + if h == 0 { + // Fallback wenn keine Höhe verfügbar + h = ScreenHeight // 720 + log.Printf("⚠️ GetFloorY: Screen height is 0, using fallback: %d", h) + } + + // Ziel: Gameplay füllt den Bildschirm optimal aus + // Erde-Tiefe: ~100px (kompakt, damit mehr Gameplay-Raum bleibt) + dirtDepth := 100.0 + + // Berechne Boden-Position: möglichst weit unten + floorY := float64(h) - dirtDepth + + // Minimum-Check: Bei sehr kleinen Bildschirmen (< 300px) mindestens 70% Höhe + minFloorY := float64(h) * 0.7 + if floorY < minFloorY { + floorY = minFloorY + } + + return floorY +} + +// GetFloorY - Wrapper der versucht die Höhe zu bekommen (deprecated, benutze GetFloorYFromHeight) +func GetFloorY() float64 { + _, h := ebiten.WindowSize() + return GetFloorYFromHeight(h) +} + +// GetScale gibt den Scale-Faktor zurück um die Spielwelt an den Bildschirm anzupassen +// Auf Mobile: Scale < 1.0 (rauszoomen, damit mehr sichtbar ist) +// Auf Desktop: Scale = 1.0 (normale Größe) +func GetScale() float64 { + _, h := ebiten.WindowSize() + if h == 0 { + h = ScreenHeight + } + + // Mobile Geräte (kleine Bildschirme): Rauszoomen für bessere Sicht + if h <= 400 { + // Sehr kleine Bildschirme: 0.7x Scale (30% rauszoomen) + return 0.7 + } else if h <= 600 { + // Mittlere Bildschirme: 0.85x Scale (15% rauszoomen) + return 0.85 + } + + // Desktop/große Bildschirme: Normale Größe + return 1.0 +} + +// GetScaleFromHeight - Scale mit expliziter Höhe +func GetScaleFromHeight(screenHeight int) float64 { + h := screenHeight + if h == 0 { + h = ScreenHeight + } + + if h <= 400 { + return 0.7 + } else if h <= 600 { + return 0.85 + } + + return 1.0 +} + +// WorldToScreenY konvertiert Server-Welt-Y-Koordinate zu Bildschirm-Y-Koordinate +// Nimmt die tatsächliche Bildschirmhöhe als Parameter (wichtig für WASM!) +func WorldToScreenYWithHeight(worldY float64, screenHeight int) float64 { + // Server arbeitet mit festen Koordinaten (Boden bei Y=540 für 720px Referenz) + // Client will Boden dynamisch positionieren + serverFloorY := float64(RefFloorY) // Server-Boden (540 bei 720px) + clientFloorY := GetFloorYFromHeight(screenHeight) // Client-Boden (dynamisch) + yOffset := clientFloorY - serverFloorY // Verschiebung (z.B. 620 - 540 = +80px nach unten) + + return worldY + yOffset +} + +// WorldToScreenY - Legacy wrapper (versucht WindowSize zu verwenden, funktioniert nicht in WASM!) +func WorldToScreenY(worldY float64) float64 { + _, h := ebiten.WindowSize() + if h == 0 { + h = ScreenHeight // Fallback + } + return WorldToScreenYWithHeight(worldY, h) +} + func isHit(x, y, w, h int) bool { if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { mx, my := ebiten.CursorPosition() diff --git a/cmd/client/main_wasm.go b/cmd/client/main_wasm.go index 1f412d1..643e83e 100644 --- a/cmd/client/main_wasm.go +++ b/cmd/client/main_wasm.go @@ -25,7 +25,7 @@ func main() { // Das Spiel wartet im Hintergrund bis startGame() von JavaScript aufgerufen wird log.Println("⏳ Warte auf Start-Signal vom HTML-Menü...") - ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + // WICHTIG: Keine feste WindowSize auf WASM - Layout() regelt die Größe dynamisch ebiten.SetWindowTitle("Escape From Teacher") ebiten.SetTPS(20) ebiten.SetVsyncEnabled(true) diff --git a/cmd/client/particles.go b/cmd/client/particles.go index 1833936..90374dc 100644 --- a/cmd/client/particles.go +++ b/cmd/client/particles.go @@ -186,6 +186,9 @@ func (g *Game) RenderParticles(screen *ebiten.Image) { g.particlesMutex.Lock() defer g.particlesMutex.Unlock() + // Canvas-Höhe für Y-Transformation + _, canvasH := screen.Size() + for i := range g.particles { p := &g.particles[i] @@ -197,9 +200,9 @@ func (g *Game) RenderParticles(screen *ebiten.Image) { col := color.RGBA{p.Color.R, p.Color.G, p.Color.B, alpha} - // Position relativ zur Kamera + // Position relativ zur Kamera und mit Y-Transformation screenX := float32(p.X - g.camX) - screenY := float32(p.Y) + screenY := float32(WorldToScreenYWithHeight(p.Y, canvasH)) // Partikel als Kreis zeichnen vector.DrawFilledCircle(screen, screenX, screenY, float32(p.Size), col, false) diff --git a/cmd/client/web/assets/assets.json b/cmd/client/web/assets/assets.json index b083502..4f4e792 100644 --- a/cmd/client/web/assets/assets.json +++ b/cmd/client/web/assets/assets.json @@ -357,7 +357,7 @@ "OffsetY": 12, "W": 55, "H": 113, - "Type": "" + "Type": "wall" } } } diff --git a/cmd/client/web/main.wasm b/cmd/client/web/main.wasm index 944b46a..6ecfb78 100755 Binary files a/cmd/client/web/main.wasm and b/cmd/client/web/main.wasm differ diff --git a/cmd/client/web/style.css b/cmd/client/web/style.css index d0d5b23..8daa2e3 100644 --- a/cmd/client/web/style.css +++ b/cmd/client/web/style.css @@ -1,7 +1,7 @@ @font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')} body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;font-family:'Press Start 2P',cursive;font-size:14px} #game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000} -canvas{position:fixed!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;max-width:100vw!important;max-height:100vh!important;object-fit:contain!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden} +canvas{position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100vh!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden} canvas.game-active{opacity:1;pointer-events:auto;z-index:2000!important;visibility:visible} .overlay-screen{position:fixed!important;top:0;left:0;width:100%;height:100%;background:url('background.jpg') center/cover no-repeat,rgba(0,0,0,.85);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px} .overlay-screen.hidden{display:none!important} diff --git a/cmd/server/websocket_gateway.go b/cmd/server/websocket_gateway.go index e5acf1e..3ed395e 100644 --- a/cmd/server/websocket_gateway.go +++ b/cmd/server/websocket_gateway.go @@ -248,15 +248,3 @@ func (c *WebSocketClient) handleMessage(msg WebSocketMessage) { log.Printf("⚠️ Unbekannter Nachrichtentyp: %s", msg.Type) } } - -// StartWebSocketGateway startet den WebSocket-Server -func StartWebSocketGateway(port string, ec *nats.EncodedConn) { - http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - handleWebSocket(w, r, ec) - }) - - log.Printf("🌐 WebSocket-Gateway läuft auf http://localhost:%s/ws", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { - log.Fatal("❌ WebSocket-Server Fehler: ", err) - } -} diff --git a/pkg/server/gateway.go b/pkg/server/gateway.go index 7bbc91b..0641a37 100644 --- a/pkg/server/gateway.go +++ b/pkg/server/gateway.go @@ -22,13 +22,18 @@ type Gateway struct { // In einer echten Microservice Welt wäre das separat, // aber hier hostet der Gateway auch Räume. LocalRooms map[string]*Room + + // Security: Tracking welcher Spieler in welchem Raum ist + // PlayerID -> RoomID Mapping + PlayerRooms map[string]string } func NewGateway(nc *nats.Conn, w *game.World) *Gateway { return &Gateway{ - NC: nc, - World: w, - LocalRooms: make(map[string]*Room), + NC: nc, + World: w, + LocalRooms: make(map[string]*Room), + PlayerRooms: make(map[string]string), } } @@ -56,6 +61,13 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { playerID := fmt.Sprintf("p_%d", time.Now().UnixNano()) roomID := login.RoomID + // 🔒 SECURITY: Prüfe ob dieser Spieler bereits in einem Raum ist + if existingRoom, exists := gw.PlayerRooms[playerID]; exists { + log.Printf("🚫 SECURITY: Player %s already in room %s, rejecting new connection", playerID, existingRoom) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"Already connected to a room"}`)) + return + } + // 2. RAUM LOGIK if login.Action == "CREATE" { // Raum ID generieren (4 Zeichen Random) @@ -69,6 +81,9 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { // Spieler lokal hinzufügen (Hack für Demo, sauberer wäre via NATS Event) newRoom.AddPlayer(playerID, login.Name) + // 🔒 SECURITY: Spieler in Raum registrieren + gw.PlayerRooms[playerID] = roomID + } else if login.Action == "JOIN" { // Wir müssen dem Raum (egal wo er läuft) sagen: Hier ist ein Neuer! // Da wir hier keine verteilte DB haben, tricksen wir: @@ -78,12 +93,15 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { if room, ok := gw.LocalRooms[roomID]; ok { room.AddPlayer(playerID, login.Name) + + // 🔒 SECURITY: Spieler in Raum registrieren + gw.PlayerRooms[playerID] = roomID } else { // Falls Raum nicht lokal: Senden wir ein "JOIN REQUEST" über NATS? // Für jetzt: Wir lassen es simpel. Wenn Raum nicht auf diesem Server -> Error. // (Für echtes Scaling bräuchten wir Redis oder NATS Request/Reply zur Raumsuche) - log.Println("Raum nicht gefunden (oder auf anderem Node):", roomID) - // Optional: Error an Client senden + log.Println("❌ Raum nicht gefunden (oder auf anderem Node):", roomID) + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"Room not found"}`)) return } } @@ -107,14 +125,37 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { // Wir parsen kurz, um den Typ zu prüfen, oder leiten blind weiter? // Besser: Wir wrappen es in ClientInput struct var raw map[string]interface{} - json.Unmarshal(data, &raw) + if err := json.Unmarshal(data, &raw); err != nil { + log.Printf("⚠️ Invalid JSON from player %s: %v", playerID, err) + continue + } inputType, _ := raw["type"].(string) + // 🔒 SECURITY: Prüfe ob der Spieler versucht für jemand anderen zu sprechen + claimedPlayerID, hasPlayerID := raw["player_id"].(string) + claimedRoomID, hasRoomID := raw["room_id"].(string) + + if hasPlayerID && claimedPlayerID != playerID { + log.Printf("🚫 SECURITY BREACH: Player %s tried to send input as %s", playerID, claimedPlayerID) + continue // Ignoriere böswilligen Input + } + + if hasRoomID && claimedRoomID != roomID { + log.Printf("🚫 SECURITY BREACH: Player %s tried to send input to room %s (is in %s)", playerID, claimedRoomID, roomID) + continue // Ignoriere böswilligen Input + } + + // 🔒 SECURITY: Setze IMMER die korrekten IDs (überschreibe Client-Werte) input := game.ClientInput{ Type: inputType, - RoomID: roomID, - PlayerID: playerID, + RoomID: roomID, // Server setzt den Raum (nicht Client!) + PlayerID: playerID, // Server setzt die Player-ID (nicht Client!) + } + + // Sequence-Nummer vom Client übernehmen (für Client Prediction) + if seq, ok := raw["sequence"].(float64); ok { + input.Sequence = uint32(seq) } bytes, _ := json.Marshal(input) @@ -122,6 +163,11 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { } // Cleanup beim Disconnect + log.Printf("✋ Player %s (%s) disconnected from Room %s", playerID, login.Name, roomID) + + // 🔒 SECURITY: Entferne Spieler aus PlayerRooms Tracking + delete(gw.PlayerRooms, playerID) + if room, ok := gw.LocalRooms[roomID]; ok { room.RemovePlayer(playerID) // Wenn leer -> Raum löschen?