diff --git a/cmd/client/atlas.go b/cmd/client/atlas.go new file mode 100644 index 0000000..1458442 --- /dev/null +++ b/cmd/client/atlas.go @@ -0,0 +1,132 @@ +package main + +import ( + "image" + "log" + "sort" + + "github.com/hajimehoshi/ebiten/v2" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +const ( + // 2px Padding verhindert Linear-Filter-Bleeding an Sprite-Grenzen + atlasPadding = 2 + // Maximale Atlas-Größe; 2048 deckt alle aktuellen Sprites locker ab + atlasMaxSize = 2048 +) + +// buildAtlas packt alle Sprite-Bilder in eine einzige GPU-Textur (Texture Atlas). +// Ebiten erkennt zusammenhängende DrawImage-Calls auf derselben Quell-Textur +// und fasst sie in einen einzigen GPU-Draw-Call zusammen ("internal batching"). +// +// Bilder vom Typ "background" und Bilder die größer als atlasMaxSize/2 sind +// bleiben als eigenständige Texturen und werden direkt in das Ergebnis übernommen. +// +// Alle anderen Sprites werden per Shelf-Packing-Algorithmus in den Atlas gepackt. +// Der Rückgabewert ist eine neue Map, in der jeder Eintrag entweder ein +// atlas.SubImage()-Ausschnitt oder das originale Einzelbild ist. +func buildAtlas(images map[string]*ebiten.Image, manifest game.AssetManifest) map[string]*ebiten.Image { + result := make(map[string]*ebiten.Image, len(images)) + + type spriteEntry struct { + id string + w int + h int + } + + var topack []spriteEntry + standalone := make(map[string]*ebiten.Image) + + for id, img := range images { + if img == nil { + continue + } + def, ok := manifest.Assets[id] + if ok && def.Type == "background" { + standalone[id] = img + continue + } + w, h := img.Bounds().Dx(), img.Bounds().Dy() + if w > atlasMaxSize/2 || h > atlasMaxSize/2 { + standalone[id] = img + continue + } + topack = append(topack, spriteEntry{id: id, w: w, h: h}) + } + + if len(topack) == 0 { + for id, img := range standalone { + result[id] = img + } + return result + } + + // Shelf-Packing: nach Höhe absteigend sortieren → dichtere Packung + sort.Slice(topack, func(i, j int) bool { + return topack[i].h > topack[j].h + }) + + type placement struct { + id string + rect image.Rectangle + } + var placements []placement + + curX := atlasPadding + curY := atlasPadding + shelfH := 0 + atlasW := 0 + atlasH := 0 + + for _, s := range topack { + pw := s.w + atlasPadding + ph := s.h + atlasPadding + + // Neue Zeile (Shelf) wenn kein Platz mehr in aktueller Zeile + if curX+pw > atlasMaxSize { + curX = atlasPadding + curY += shelfH + atlasPadding + shelfH = 0 + } + + // Passt nicht mehr in den Atlas → als Standalone behalten + if curY+ph > atlasMaxSize { + standalone[s.id] = images[s.id] + continue + } + + r := image.Rect(curX, curY, curX+s.w, curY+s.h) + placements = append(placements, placement{id: s.id, rect: r}) + + if s.h > shelfH { + shelfH = s.h + } + if curX+pw > atlasW { + atlasW = curX + pw + } + if curY+s.h+atlasPadding > atlasH { + atlasH = curY + s.h + atlasPadding + } + curX += pw + } + + // Atlas-Textur anlegen und Sprites hineinzeichnen + atlas := ebiten.NewImage(atlasW, atlasH) + for _, p := range placements { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(p.rect.Min.X), float64(p.rect.Min.Y)) + atlas.DrawImage(images[p.id], op) + result[p.id] = atlas.SubImage(p.rect).(*ebiten.Image) + } + + for id, img := range standalone { + result[id] = img + } + + log.Printf("🗺️ Texture Atlas: %dx%d px | %d Sprites gepackt | %d standalone", + atlasW, atlasH, len(placements), len(standalone)) + + return result +} diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 84756bd..abebae5 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -371,6 +371,19 @@ func (g *Game) DrawGame(screen *ebiten.Image) { effectiveCamX := g.camX / viewScale g.RenderGround(screen, effectiveCamX) + // Prediction-Snapshot VOR dem stateMutex holen (verhindert Deadlock: + // DrawGame würde sonst stateMutex→predictionMutex halten, während + // ReconcileWithServer predictionMutex→stateMutex hält) + g.predictionMutex.Lock() + snapPrevX := g.prevPredictedX + snapPrevY := g.prevPredictedY + snapPredX := g.predictedX + snapPredY := g.predictedY + snapOffX := g.correctionOffsetX + snapOffY := g.correctionOffsetY + snapPhysTime := g.lastPhysicsTime + g.predictionMutex.Unlock() + // State Locken für Datenzugriff g.stateMutex.Lock() defer g.stateMutex.Unlock() @@ -434,15 +447,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) { // Für lokalen Spieler: Client-Prediction mit Interpolation zwischen Physics-Steps // Physics läuft bei 20/sec, Draw bei 60fps → alpha interpoliert dazwischen if p.Name == g.playerName { - g.predictionMutex.Lock() // Interpolations-Alpha: wie weit sind wir zwischen letztem und nächstem Physics-Step? - alpha := float64(time.Since(g.lastPhysicsTime)) / float64(50*time.Millisecond) + alpha := float64(time.Since(snapPhysTime)) / float64(50*time.Millisecond) if alpha > 1 { alpha = 1 } - posX = g.prevPredictedX + (g.predictedX-g.prevPredictedX)*alpha + g.correctionOffsetX - posY = g.prevPredictedY + (g.predictedY-g.prevPredictedY)*alpha + g.correctionOffsetY - g.predictionMutex.Unlock() + posX = snapPrevX + (snapPredX-snapPrevX)*alpha + snapOffX + posY = snapPrevY + (snapPredY-snapPrevY)*alpha + snapOffY } // Wähle Sprite basierend auf Sprung-Status