Private
Public Access
1
0

Add platform-specific implementations for assets, audio, WebSocket, and rendering on Desktop and WebAssembly platforms. Introduce embedded assets for WebAssembly and native file handling for Desktop. Add platform-specific chunk loading and game state synchronization.

This commit is contained in:
Sebastian Unterschütz
2026-01-04 01:25:04 +01:00
parent 85d697df19
commit 3232ee7c2f
86 changed files with 4931 additions and 486 deletions

View File

@@ -2,6 +2,7 @@ package server
import (
"log"
"math"
"math/rand"
"sync"
"time"
@@ -12,34 +13,70 @@ import (
)
type ServerPlayer struct {
ID string
Name string
X, Y float64
VX, VY float64
OnGround bool
InputX float64 // -1 (Links), 0, 1 (Rechts)
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
Score int
IsAlive bool
IsSpectator bool
ID string
Name string
X, Y float64
VX, VY float64
OnGround bool
OnWall bool // Ist an einer Wand
OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
InputX float64 // -1 (Links), 0, 1 (Rechts)
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
Score int
IsAlive bool
IsSpectator bool
// Powerups
HasDoubleJump bool // Doppelsprung aktiv?
DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt?
HasGodMode bool // Godmode aktiv?
GodModeEndTime time.Time // Wann endet Godmode?
}
type MovingPlatform struct {
ChunkID string // Welcher Chunk
ObjectIdx int // Index im Chunk
AssetID string // Asset-ID
CurrentX float64 // Aktuelle Position X (Welt-Koordinaten)
CurrentY float64 // Aktuelle Position Y
StartX float64 // Start-Position X (Welt-Koordinaten)
StartY float64 // Start-Position Y
EndX float64 // End-Position X (Welt-Koordinaten)
EndY float64 // End-Position Y
Speed float64 // Geschwindigkeit
Direction float64 // 1.0 = zu End, -1.0 = zu Start
IsActive bool // Hat die Bewegung bereits begonnen?
HitboxW float64 // Cached Hitbox
HitboxH float64
DrawOffX float64
DrawOffY float64
HitboxOffX float64
HitboxOffY float64
}
type Room struct {
ID string
NC *nats.Conn
World *game.World
Mutex sync.RWMutex
Players map[string]*ServerPlayer
ActiveChunks []game.ActiveChunk
Colliders []game.Collider
Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER"
GlobalScrollX float64
MapEndX float64
Countdown int
NextStart time.Time
HostID string
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
ScoreAccum float64 // Akkumulator für Distanz-Score
ID string
NC *nats.Conn
World *game.World
Mutex sync.RWMutex
Players map[string]*ServerPlayer
ActiveChunks []game.ActiveChunk
Colliders []game.Collider
Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER"
GlobalScrollX float64
MapEndX float64
Countdown int
NextStart time.Time
HostID string
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
CollectedPowerups map[string]bool // Key: "chunkID_objectIndex"
ScoreAccum float64 // Akkumulator für Distanz-Score
// Chunk-Pool für fairen Random-Spawn
ChunkPool []string // Verfügbare Chunks für nächsten Spawn
ChunkSpawnedCount map[string]int // Wie oft wurde jeder Chunk gespawnt
MovingPlatforms []*MovingPlatform // Aktive bewegende Plattformen
firstBroadcast bool // Wurde bereits geloggt?
stopChan chan struct{}
@@ -54,16 +91,21 @@ type Room struct {
// Konstruktor
func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
r := &Room{
ID: id,
NC: nc,
World: w,
Players: make(map[string]*ServerPlayer),
Status: "LOBBY",
stopChan: make(chan struct{}),
CollectedCoins: make(map[string]bool),
pW: 40, pH: 60, // Fallback
ID: id,
NC: nc,
World: w,
Players: make(map[string]*ServerPlayer),
Status: "LOBBY",
stopChan: make(chan struct{}),
CollectedCoins: make(map[string]bool),
CollectedPowerups: make(map[string]bool),
ChunkSpawnedCount: make(map[string]int),
pW: 40, pH: 60, // Fallback
}
// Initialisiere Chunk-Pool mit allen verfügbaren Chunks
r.RefillChunkPool()
// Player Werte aus Manifest laden
if def, ok := w.Manifest.Assets["player"]; ok {
r.pW = def.Hitbox.W
@@ -214,6 +256,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
if p.OnGround {
p.VY = -14.0
p.OnGround = false
p.DoubleJumpUsed = false // Reset double jump on ground jump
} else if p.HasDoubleJump && !p.DoubleJumpUsed {
// Double Jump in der Luft
p.VY = -14.0
p.DoubleJumpUsed = true
log.Printf("⚡ %s verwendet Double Jump!", p.Name)
}
case "DOWN":
p.VY = 15.0
@@ -256,6 +304,8 @@ func (r *Room) Update() {
}
} else if r.Status == "RUNNING" {
r.GlobalScrollX += config.RunSpeed
// Bewegende Plattformen updaten
r.UpdateMovingPlatforms()
}
maxX := r.GlobalScrollX
@@ -282,20 +332,38 @@ func (r *Room) Update() {
}
// X Bewegung
currentSpeed := config.RunSpeed + (p.InputX * 4.0)
// Symmetrische Geschwindigkeit: Links = Rechts
// Nach rechts: RunSpeed + 11, Nach links: RunSpeed - 11
// Ergebnis: Rechts = 18, Links = -4 (beide gleich weit vom Scroll)
currentSpeed := config.RunSpeed + (p.InputX * 11.0)
nextX := p.X + currentSpeed
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitX {
if typeX == "obstacle" {
// Nicht blocken, sondern weiterlaufen und töten
p.X = nextX
r.KillPlayer(p)
continue
if typeX == "wall" {
// Wand getroffen - kann klettern!
p.OnWall = true
// X-Position nicht ändern (bleibt an der Wand)
} else if typeX == "obstacle" {
// Godmode prüfen
if p.HasGodMode && time.Now().Before(p.GodModeEndTime) {
// Mit Godmode - Obstacle wird zerstört, Spieler überlebt
p.X = nextX
// TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle)
log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name)
} else {
// Ohne Godmode - Spieler stirbt
p.X = nextX
r.KillPlayer(p)
continue
}
} else {
// Platform blockiert
p.OnWall = false
}
// Platform blockiert
} else {
p.X = nextX
p.OnWall = false
}
// Grenzen
@@ -312,28 +380,85 @@ func (r *Room) Update() {
}
// Y Bewegung
p.VY += config.Gravity
if p.VY > config.MaxFall {
p.VY = config.MaxFall
// An der Wand: Reduzierte Gravität + Klettern mit InputX
if p.OnWall {
// Wandrutschen (langsame Fallgeschwindigkeit)
p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand
if p.VY > 3.0 {
p.VY = 3.0 // Maximal 3.0 beim Rutschen
}
// Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung)
if p.InputX != 0 {
p.VY = -5.0 // Kletter-Geschwindigkeit nach oben
}
} else {
// Normal: Volle Gravität
p.VY += config.Gravity
if p.VY > config.MaxFall {
p.VY = config.MaxFall
}
}
nextY := p.Y + p.VY
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitY {
if typeY == "obstacle" {
// Nicht blocken, sondern weiterlaufen und töten
if typeY == "wall" {
// An der Wand: Nicht töten, sondern Position halten
if p.OnWall {
p.VY = 0
} else {
// Von oben/unten gegen Wand - töten (kein Klettern in Y-Richtung)
p.Y = nextY
r.KillPlayer(p)
continue
}
} else if typeY == "obstacle" {
// Obstacle - immer töten
p.Y = nextY
r.KillPlayer(p)
continue
} else {
// Platform blockiert
if p.VY > 0 {
p.OnGround = true
// Prüfe ob auf bewegender Plattform
platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
p.OnMovingPlatform = platform
}
p.VY = 0
}
// Platform blockiert
if p.VY > 0 {
p.OnGround = true
}
p.VY = 0
} else {
p.Y += p.VY
p.OnGround = false
p.OnMovingPlatform = nil
}
// Spieler bewegt sich mit Plattform mit
if p.OnMovingPlatform != nil && p.OnGround {
// Berechne Plattform-Geschwindigkeit
mp := p.OnMovingPlatform
var targetX, targetY float64
if mp.Direction > 0 {
targetX, targetY = mp.EndX, mp.EndY
} else {
targetX, targetY = mp.StartX, mp.StartY
}
dx := targetX - mp.CurrentX
dy := targetY - mp.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
if dist > 0.1 {
movePerTick := mp.Speed / 60.0
platformVelX := (dx / dist) * movePerTick
platformVelY := (dy / dist) * movePerTick
// Übertrage Plattform-Geschwindigkeit auf Spieler
p.X += platformVelX
p.Y += platformVelY
}
}
if p.Y > 1000 {
@@ -342,6 +467,15 @@ func (r *Room) Update() {
// Coin Kollision prüfen
r.CheckCoinCollision(p)
// Powerup Kollision prüfen
r.CheckPowerupCollision(p)
// Godmode Timeout prüfen
if p.HasGodMode && time.Now().After(p.GodModeEndTime) {
p.HasGodMode = false
log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name)
}
}
// 2b. Distanz-Score updaten
@@ -380,6 +514,7 @@ func (r *Room) Update() {
func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
// 1. Statische Colliders (Chunks)
for _, c := range r.Colliders {
if game.CheckRectCollision(playerRect, c.Rect) {
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)",
@@ -390,9 +525,44 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
}
}
// 2. Bewegende Plattformen (dynamische Colliders)
for _, mp := range r.MovingPlatforms {
// Berechne Plattform-Hitbox an aktueller Position
mpRect := game.Rect{
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
W: mp.HitboxW,
H: mp.HitboxH,
}
if game.CheckRectCollision(playerRect, mpRect) {
return true, "platform"
}
}
return false, ""
}
// CheckMovingPlatformLanding prüft ob Spieler auf einer bewegenden Plattform landet
func (r *Room) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
for _, mp := range r.MovingPlatforms {
mpRect := game.Rect{
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
W: mp.HitboxW,
H: mp.HitboxH,
}
if game.CheckRectCollision(playerRect, mpRect) {
return mp
}
}
return nil
}
func (r *Room) UpdateMapLogic(maxX float64) {
if r.Status != "RUNNING" {
return
@@ -413,26 +583,178 @@ func (r *Room) UpdateMapLogic(maxX float64) {
chunkWidth := float64(chunkDef.Width * config.TileSize)
if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 {
// Lösche alle Coins dieses Chunks aus CollectedCoins
r.ClearChunkCoins(firstChunk.ChunkID)
// Lösche alle Powerups dieses Chunks
r.ClearChunkPowerups(firstChunk.ChunkID)
// Entferne bewegende Plattformen dieses Chunks
r.RemoveMovingPlatforms(firstChunk.ChunkID)
r.ActiveChunks = r.ActiveChunks[1:]
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID)
}
}
}
func (r *Room) SpawnNextChunk() {
keys := make([]string, 0, len(r.World.ChunkLibrary))
for k := range r.World.ChunkLibrary {
keys = append(keys, k)
// ClearChunkCoins löscht alle eingesammelten Coins eines Chunks
func (r *Room) ClearChunkCoins(chunkID string) {
prefix := chunkID + "_"
coinsCleared := 0
for key := range r.CollectedCoins {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(r.CollectedCoins, key)
coinsCleared++
}
}
if coinsCleared > 0 {
log.Printf("💰 %d Coins von Chunk %s zurückgesetzt", coinsCleared, chunkID)
}
}
// InitMovingPlatforms initialisiert bewegende Plattformen für einen Chunk
func (r *Room) InitMovingPlatforms(chunkID string, chunkWorldX float64) {
chunkDef, exists := r.World.ChunkLibrary[chunkID]
if !exists {
return
}
if len(keys) > 0 {
// Zufälligen Chunk wählen
randomID := keys[rand.Intn(len(keys))]
chunkDef := r.World.ChunkLibrary[randomID]
for objIdx, obj := range chunkDef.Objects {
if obj.MovingPlatform != nil {
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
if !ok || assetDef.Type != "platform" {
continue
}
mpData := obj.MovingPlatform
platform := &MovingPlatform{
ChunkID: chunkID,
ObjectIdx: objIdx,
AssetID: obj.AssetID,
StartX: chunkWorldX + mpData.StartX,
StartY: mpData.StartY,
EndX: chunkWorldX + mpData.EndX,
EndY: mpData.EndY,
Speed: mpData.Speed,
Direction: 1.0, // Start bei StartX, bewege zu EndX
HitboxW: assetDef.Hitbox.W,
HitboxH: assetDef.Hitbox.H,
DrawOffX: assetDef.DrawOffX,
DrawOffY: assetDef.DrawOffY,
HitboxOffX: assetDef.Hitbox.OffsetX,
HitboxOffY: assetDef.Hitbox.OffsetY,
}
platform.CurrentX = platform.StartX
platform.CurrentY = platform.StartY
r.MovingPlatforms = append(r.MovingPlatforms, platform)
log.Printf("🔄 Bewegende Plattform initialisiert: %s in Chunk %s", obj.AssetID, chunkID)
}
}
}
// RemoveMovingPlatforms entfernt alle Plattformen eines Chunks
func (r *Room) RemoveMovingPlatforms(chunkID string) {
newPlatforms := make([]*MovingPlatform, 0)
removedCount := 0
for _, p := range r.MovingPlatforms {
if p.ChunkID != chunkID {
newPlatforms = append(newPlatforms, p)
} else {
removedCount++
}
}
r.MovingPlatforms = newPlatforms
if removedCount > 0 {
log.Printf("🗑️ %d bewegende Plattformen von Chunk %s entfernt", removedCount, chunkID)
}
}
// UpdateMovingPlatforms bewegt alle aktiven Plattformen
func (r *Room) UpdateMovingPlatforms() {
// Sichtbarer Bereich: GlobalScrollX bis GlobalScrollX + 1400
// Aktivierung bei 3/4: GlobalScrollX + (1400 * 3/4) = GlobalScrollX + 1050
activationPoint := r.GlobalScrollX + 1050
for _, p := range r.MovingPlatforms {
// Prüfe ob Plattform den Aktivierungspunkt erreicht hat
if !p.IsActive {
// Aktiviere Plattform, wenn sie bei 3/4 des Bildschirms ist
if p.CurrentX <= activationPoint {
p.IsActive = true
log.Printf("▶️ Plattform aktiviert: %s (X=%.0f)", p.ChunkID, p.CurrentX)
} else {
// Noch nicht weit genug gescrollt, nicht bewegen
continue
}
}
// Bewegung berechnen (Speed pro Sekunde, bei 60 FPS = Speed/60)
movePerTick := p.Speed / 60.0
// Bewegungsvektor von CurrentPos zu Ziel
var targetX, targetY float64
if p.Direction > 0 {
targetX, targetY = p.EndX, p.EndY
} else {
targetX, targetY = p.StartX, p.StartY
}
dx := targetX - p.CurrentX
dy := targetY - p.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
if dist < movePerTick {
// Ziel erreicht, umkehren
p.CurrentX = targetX
p.CurrentY = targetY
p.Direction *= -1.0
} else {
// Weiterbewegen
p.CurrentX += (dx / dist) * movePerTick
p.CurrentY += (dy / dist) * movePerTick
}
}
}
// RefillChunkPool füllt den Pool mit allen verfügbaren Chunks
func (r *Room) RefillChunkPool() {
r.ChunkPool = make([]string, 0, len(r.World.ChunkLibrary))
for chunkID := range r.World.ChunkLibrary {
if chunkID != "start" { // Start-Chunk nicht in Pool
r.ChunkPool = append(r.ChunkPool, chunkID)
}
}
// Mische Pool für zufällige Reihenfolge
rand.Shuffle(len(r.ChunkPool), func(i, j int) {
r.ChunkPool[i], r.ChunkPool[j] = r.ChunkPool[j], r.ChunkPool[i]
})
log.Printf("🔄 Chunk-Pool neu gefüllt: %d Chunks", len(r.ChunkPool))
}
func (r *Room) SpawnNextChunk() {
// Pool leer? Nachfüllen!
if len(r.ChunkPool) == 0 {
r.RefillChunkPool()
}
if len(r.ChunkPool) > 0 {
// Nimm ersten Chunk aus Pool (bereits gemischt)
randomID := r.ChunkPool[0]
r.ChunkPool = r.ChunkPool[1:] // Entferne aus Pool
chunkDef := r.World.ChunkLibrary[randomID]
newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX}
r.ActiveChunks = append(r.ActiveChunks, newChunk)
r.MapEndX += float64(chunkDef.Width * config.TileSize)
// Initialisiere bewegende Plattformen für diesen Chunk
r.InitMovingPlatforms(randomID, newChunk.X)
r.ChunkSpawnedCount[randomID]++
log.Printf("🎲 Chunk gespawnt: %s (Total: %d mal, Pool: %d übrig)", randomID, r.ChunkSpawnedCount[randomID], len(r.ChunkPool))
} else {
// Fallback, falls keine Chunks da sind
r.MapEndX += 1280
@@ -446,35 +768,52 @@ func (r *Room) Broadcast() {
defer r.Mutex.RUnlock()
state := game.GameState{
RoomID: r.ID,
Players: make(map[string]game.PlayerState),
Status: r.Status,
TimeLeft: r.Countdown,
WorldChunks: r.ActiveChunks,
HostID: r.HostID,
ScrollX: r.GlobalScrollX,
CollectedCoins: r.CollectedCoins,
RoomID: r.ID,
Players: make(map[string]game.PlayerState),
Status: r.Status,
TimeLeft: r.Countdown,
WorldChunks: r.ActiveChunks,
HostID: r.HostID,
ScrollX: r.GlobalScrollX,
CollectedCoins: r.CollectedCoins,
CollectedPowerups: r.CollectedPowerups,
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
}
for id, p := range r.Players {
state.Players[id] = game.PlayerState{
ID: id,
Name: p.Name,
X: p.X,
Y: p.Y,
VX: p.VX,
VY: p.VY,
OnGround: p.OnGround,
LastInputSeq: p.LastInputSeq,
Score: p.Score,
IsAlive: p.IsAlive,
IsSpectator: p.IsSpectator,
ID: id,
Name: p.Name,
X: p.X,
Y: p.Y,
VX: p.VX,
VY: p.VY,
OnGround: p.OnGround,
OnWall: p.OnWall,
LastInputSeq: p.LastInputSeq,
Score: p.Score,
IsAlive: p.IsAlive,
IsSpectator: p.IsSpectator,
HasDoubleJump: p.HasDoubleJump,
HasGodMode: p.HasGodMode,
}
}
// Bewegende Plattformen synchronisieren
for _, mp := range r.MovingPlatforms {
state.MovingPlatforms = append(state.MovingPlatforms, game.MovingPlatformSync{
ChunkID: mp.ChunkID,
ObjectIdx: mp.ObjectIdx,
AssetID: mp.AssetID,
X: mp.CurrentX,
Y: mp.CurrentY,
})
}
// DEBUG: Ersten Broadcast loggen (nur beim ersten Mal)
if len(r.Players) > 0 && r.Status == "LOBBY" {
if !r.firstBroadcast && len(r.Players) > 0 && r.Status == "LOBBY" {
log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status)
r.firstBroadcast = true
}
// Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
@@ -490,3 +829,18 @@ func (r *Room) RemovePlayer(id string) {
delete(r.Players, id)
log.Printf(" Player %s left room %s", id, r.ID)
}
// ClearChunkPowerups löscht alle eingesammelten Powerups eines Chunks
func (r *Room) ClearChunkPowerups(chunkID string) {
prefix := chunkID + "_"
powerupsCleared := 0
for key := range r.CollectedPowerups {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(r.CollectedPowerups, key)
powerupsCleared++
}
}
if powerupsCleared > 0 {
log.Printf("⚡ %d Powerups von Chunk %s zurückgesetzt", powerupsCleared, chunkID)
}
}