Integrate shared physics engine for player movement and collision handling, refine 20 TPS gameplay logic, and enhance client prediction with server-reconciliation updates.
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 7m51s
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 7m51s
This commit is contained in:
@@ -88,9 +88,17 @@ func (g *Game) wsReadPump() {
|
|||||||
// Out-of-Order-Erkennung: Ignoriere alte Updates
|
// Out-of-Order-Erkennung: Ignoriere alte Updates
|
||||||
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
|
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
|
||||||
// Alte Nachricht - ignorieren
|
// Alte Nachricht - ignorieren
|
||||||
|
g.outOfOrderCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
g.lastRecvSeq = state.Sequence
|
g.lastRecvSeq = state.Sequence
|
||||||
|
g.totalUpdates++
|
||||||
|
g.lastUpdateTime = time.Now()
|
||||||
|
|
||||||
|
// Aktualisiere CurrentSpeed für Client-Prediction
|
||||||
|
g.predictionMutex.Lock()
|
||||||
|
g.currentSpeed = state.CurrentSpeed
|
||||||
|
g.predictionMutex.Unlock()
|
||||||
|
|
||||||
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
||||||
for _, p := range state.Players {
|
for _, p := range state.Players {
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ func (g *Game) connectToServer() {
|
|||||||
// Out-of-Order-Erkennung: Ignoriere alte Updates
|
// Out-of-Order-Erkennung: Ignoriere alte Updates
|
||||||
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
|
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
|
||||||
// Alte Nachricht - ignorieren
|
// Alte Nachricht - ignorieren
|
||||||
|
g.outOfOrderCount++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
g.lastRecvSeq = state.Sequence
|
g.lastRecvSeq = state.Sequence
|
||||||
|
g.totalUpdates++
|
||||||
|
g.lastUpdateTime = time.Now()
|
||||||
|
|
||||||
|
// Aktualisiere CurrentSpeed für Client-Prediction
|
||||||
|
g.predictionMutex.Lock()
|
||||||
|
g.currentSpeed = state.CurrentSpeed
|
||||||
|
g.predictionMutex.Unlock()
|
||||||
|
|
||||||
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
||||||
for _, p := range state.Players {
|
for _, p := range state.Players {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||||
@@ -71,45 +72,18 @@ func (g *Game) UpdateGame() {
|
|||||||
}
|
}
|
||||||
g.btnJumpActive = false
|
g.btnJumpActive = false
|
||||||
|
|
||||||
// --- 4. CLIENT PREDICTION ---
|
// --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) ---
|
||||||
if g.connected {
|
if g.connected {
|
||||||
g.predictionMutex.Lock()
|
g.predictionMutex.Lock()
|
||||||
// Sequenznummer erhöhen
|
// Sequenznummer erhöhen
|
||||||
g.inputSequence++
|
g.inputSequence++
|
||||||
input.Sequence = g.inputSequence
|
input.Sequence = g.inputSequence
|
||||||
|
|
||||||
// Input speichern für später Reconciliation
|
// Lokale Prediction ausführen für sofortiges Feedback
|
||||||
g.pendingInputs[input.Sequence] = input
|
|
||||||
|
|
||||||
// Lokale Physik sofort anwenden (Prediction)
|
|
||||||
g.ApplyInput(input)
|
g.ApplyInput(input)
|
||||||
|
|
||||||
// Sanfte Korrektur anwenden (langsamer bei 20 TPS für weniger Jitter)
|
// Input für History speichern (für Server-Reconciliation)
|
||||||
const smoothingFactor = 0.15 // Reduziert für 20 TPS (war 0.4 bei 60 TPS)
|
g.pendingInputs[input.Sequence] = input
|
||||||
if g.correctionX != 0 || g.correctionY != 0 {
|
|
||||||
g.predictedX += g.correctionX * smoothingFactor
|
|
||||||
g.predictedY += g.correctionY * smoothingFactor
|
|
||||||
|
|
||||||
g.correctionX *= (1.0 - smoothingFactor)
|
|
||||||
g.correctionY *= (1.0 - smoothingFactor)
|
|
||||||
|
|
||||||
// Korrektur beenden wenn sehr klein
|
|
||||||
if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 1.0 {
|
|
||||||
g.correctionX = 0
|
|
||||||
g.correctionY = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Landing Detection für Partikel
|
|
||||||
if !g.lastGroundState && g.predictedGround {
|
|
||||||
// Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen)
|
|
||||||
// Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H
|
|
||||||
// = Y - 231 + 42 + 184 = Y - 5
|
|
||||||
feetY := g.predictedY - 231 + 42 + 184
|
|
||||||
centerX := g.predictedX - 56 + 68 + 73/2
|
|
||||||
g.SpawnLandingParticles(centerX, feetY)
|
|
||||||
}
|
|
||||||
g.lastGroundState = g.predictedGround
|
|
||||||
|
|
||||||
g.predictionMutex.Unlock()
|
g.predictionMutex.Unlock()
|
||||||
|
|
||||||
@@ -262,23 +236,26 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
backgroundID = "background1"
|
backgroundID = "background1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
|
// Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe)
|
||||||
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
|
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
|
||||||
op := &ebiten.DrawImageOptions{}
|
op := &ebiten.DrawImageOptions{}
|
||||||
|
|
||||||
// Skalierung berechnen, um Bildschirm zu füllen
|
// Tatsächliche Canvas-Größe verwenden (nicht nur ScreenWidth/Height)
|
||||||
|
canvasW, canvasH := screen.Size()
|
||||||
bgW, bgH := bgImg.Size()
|
bgW, bgH := bgImg.Size()
|
||||||
scaleX := float64(ScreenWidth) / float64(bgW)
|
|
||||||
scaleY := float64(ScreenHeight) / float64(bgH)
|
// Skalierung berechnen, um Canvas komplett zu füllen
|
||||||
|
scaleX := float64(canvasW) / float64(bgW)
|
||||||
|
scaleY := float64(canvasH) / float64(bgH)
|
||||||
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
|
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
|
||||||
|
|
||||||
op.GeoM.Scale(scale, scale)
|
op.GeoM.Scale(scale, scale)
|
||||||
|
|
||||||
// Zentrieren
|
// Zentrieren auf Canvas
|
||||||
scaledW := float64(bgW) * scale
|
scaledW := float64(bgW) * scale
|
||||||
scaledH := float64(bgH) * scale
|
scaledH := float64(bgH) * scale
|
||||||
offsetX := (float64(ScreenWidth) - scaledW) / 2
|
offsetX := (float64(canvasW) - scaledW) / 2
|
||||||
offsetY := (float64(ScreenHeight) - scaledH) / 2
|
offsetY := (float64(canvasH) - scaledH) / 2
|
||||||
op.GeoM.Translate(offsetX, offsetY)
|
op.GeoM.Translate(offsetX, offsetY)
|
||||||
|
|
||||||
screen.DrawImage(bgImg, op)
|
screen.DrawImage(bgImg, op)
|
||||||
@@ -336,26 +313,22 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
|
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Spieler
|
// 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - UNTER dem Gras bis tief in die Erde
|
||||||
// MyID ohne Lock holen (wir haben bereits den stateMutex)
|
vector.StrokeRect(screen, float32(-g.camX), float32(540), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false)
|
||||||
myID := ""
|
|
||||||
for id, p := range g.gameState.Players {
|
|
||||||
if p.Name == g.playerName {
|
|
||||||
myID = id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 3. Spieler
|
||||||
for id, p := range g.gameState.Players {
|
for id, p := range g.gameState.Players {
|
||||||
// Für lokalen Spieler: Verwende vorhergesagte Position
|
|
||||||
posX, posY := p.X, p.Y
|
posX, posY := p.X, p.Y
|
||||||
vy := p.VY
|
vy := p.VY
|
||||||
onGround := p.OnGround
|
onGround := p.OnGround
|
||||||
if id == myID && g.connected {
|
|
||||||
|
// Für lokalen Spieler: Verwende Client-Prediction Position
|
||||||
|
// Die Reconciliation wird in ReconcileWithServer() (connection_*.go) gemacht
|
||||||
|
if p.Name == g.playerName {
|
||||||
|
g.predictionMutex.Lock()
|
||||||
posX = g.predictedX
|
posX = g.predictedX
|
||||||
posY = g.predictedY
|
posY = g.predictedY
|
||||||
vy = g.predictedVY
|
g.predictionMutex.Unlock()
|
||||||
onGround = g.predictedGround
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wähle Sprite basierend auf Sprung-Status
|
// Wähle Sprite basierend auf Sprung-Status
|
||||||
@@ -385,82 +358,98 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
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(posY-25), ColText)
|
||||||
|
|
||||||
// DEBUG: Rote Hitbox
|
// HITBOX VISUALISIERUNG (IMMER SICHTBAR)
|
||||||
if def, ok := g.world.Manifest.Assets["player"]; ok {
|
if def, ok := g.world.Manifest.Assets["player"]; ok {
|
||||||
|
// Spieler-Hitbox (ROT)
|
||||||
hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
|
hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
|
||||||
hy := float32(posY + def.DrawOffY + def.Hitbox.OffsetY)
|
hy := float32(posY + def.DrawOffY + def.Hitbox.OffsetY)
|
||||||
vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 2, color.RGBA{255, 0, 0, 255}, false)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. UI Status
|
// 4. UI Status (Canvas-relativ)
|
||||||
|
canvasW, canvasH := screen.Size()
|
||||||
|
|
||||||
if g.gameState.Status == "COUNTDOWN" {
|
if g.gameState.Status == "COUNTDOWN" {
|
||||||
msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft)
|
msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft)
|
||||||
text.Draw(screen, msg, basicfont.Face7x13, ScreenWidth/2-40, ScreenHeight/2, color.RGBA{255, 255, 0, 255})
|
text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255})
|
||||||
} else if g.gameState.Status == "RUNNING" {
|
} else if g.gameState.Status == "RUNNING" {
|
||||||
|
// Score/Distance Anzeige mit grauem Hintergrund (oben rechts)
|
||||||
dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
|
dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
|
||||||
text.Draw(screen, dist, basicfont.Face7x13, ScreenWidth-150, 30, ColText)
|
|
||||||
|
|
||||||
// Score anzeigen
|
|
||||||
scoreStr := fmt.Sprintf("Score: %d", myScore)
|
scoreStr := fmt.Sprintf("Score: %d", myScore)
|
||||||
text.Draw(screen, scoreStr, basicfont.Face7x13, ScreenWidth-150, 50, ColText)
|
|
||||||
|
// Berechne Textbreiten für dynamische Box-Größe
|
||||||
|
distLen := len(dist) * 7 // ~7px pro Zeichen
|
||||||
|
scoreLen := len(scoreStr) * 7
|
||||||
|
maxWidth := distLen
|
||||||
|
if scoreLen > maxWidth {
|
||||||
|
maxWidth = scoreLen
|
||||||
|
}
|
||||||
|
|
||||||
|
boxWidth := float32(maxWidth + 20) // 10px Padding links/rechts
|
||||||
|
boxHeight := float32(50)
|
||||||
|
boxX := float32(canvasW) - boxWidth - 10 // 10px vom rechten Rand
|
||||||
|
boxY := float32(10) // 10px vom oberen Rand
|
||||||
|
|
||||||
|
// Grauer halbtransparenter Hintergrund
|
||||||
|
vector.DrawFilledRect(screen, boxX, boxY, boxWidth, boxHeight, color.RGBA{60, 60, 60, 200}, false)
|
||||||
|
vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2, color.RGBA{100, 100, 100, 255}, false)
|
||||||
|
|
||||||
|
// Text (zentriert in Box)
|
||||||
|
textX := int(boxX) + 10
|
||||||
|
text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.RGBA{255, 255, 255, 255})
|
||||||
|
text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255})
|
||||||
|
|
||||||
// Spectator Overlay wenn tot
|
// Spectator Overlay wenn tot
|
||||||
if isDead {
|
if isDead {
|
||||||
// Halbtransparenter roter Overlay
|
// Halbtransparenter roter Overlay (volle Canvas-Breite)
|
||||||
vector.DrawFilledRect(screen, 0, 0, ScreenWidth, 80, color.RGBA{150, 0, 0, 180}, false)
|
vector.DrawFilledRect(screen, 0, 0, float32(canvasW), 80, color.RGBA{150, 0, 0, 180}, false)
|
||||||
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, ScreenWidth/2-140, 30, color.White)
|
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, canvasW/2-140, 30, color.White)
|
||||||
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, ScreenWidth/2-90, 55, color.RGBA{255, 255, 0, 255})
|
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, canvasW/2-90, 55, color.RGBA{255, 255, 0, 255})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. DEBUG: TODES-LINIE
|
// 5. DEBUG: TODES-LINIE (volle Canvas-Höhe)
|
||||||
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
|
vector.StrokeLine(screen, 0, 0, 0, float32(canvasH), 10, color.RGBA{255, 0, 0, 128}, false)
|
||||||
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
|
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, canvasH/2, color.RGBA{255, 0, 0, 255})
|
||||||
|
|
||||||
// 6. PARTIKEL RENDERN (vor UI)
|
// 6. PARTIKEL RENDERN (vor UI)
|
||||||
g.RenderParticles(screen)
|
g.RenderParticles(screen)
|
||||||
|
|
||||||
// 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
|
// 7. DEBUG OVERLAY (F3 zum Umschalten)
|
||||||
if !g.keyboardUsed {
|
if g.showDebug {
|
||||||
// A) Joystick Base (dunkelgrau und durchsichtig)
|
g.drawDebugOverlay(screen)
|
||||||
baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig
|
}
|
||||||
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 (dunkelgrau, außer wenn aktiv)
|
// 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
|
||||||
knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
|
if !g.keyboardUsed {
|
||||||
|
canvasW, canvasH := screen.Size()
|
||||||
|
|
||||||
|
// A) Joystick Base (unten links, relativ zu Canvas)
|
||||||
|
joyX := 150.0
|
||||||
|
joyY := float64(canvasH) - 150.0
|
||||||
|
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)
|
||||||
|
|
||||||
|
// B) Joystick Knob (relativ zu Base, nicht zu Canvas)
|
||||||
|
knobCol := color.RGBA{100, 100, 100, 80}
|
||||||
if g.joyActive {
|
if g.joyActive {
|
||||||
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
|
knobCol = color.RGBA{100, 255, 100, 120}
|
||||||
}
|
}
|
||||||
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
|
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
|
||||||
|
|
||||||
// C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung)
|
// C) Jump Button (unten rechts, relativ zu Canvas)
|
||||||
jumpX := float32(ScreenWidth - 150)
|
jumpX := float32(canvasW) - 150
|
||||||
jumpY := float32(ScreenHeight - 150)
|
jumpY := float32(canvasH) - 150
|
||||||
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
|
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)
|
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})
|
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. DEBUG INFO (Oben Links)
|
|
||||||
myPosStr := "N/A"
|
|
||||||
for _, p := range g.gameState.Players {
|
|
||||||
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
debugMsg := fmt.Sprintf(
|
|
||||||
"FPS: %.2f\nState: %s\nPlayers: %d\nCamX: %.0f\nPos: %s",
|
|
||||||
ebiten.CurrentFPS(),
|
|
||||||
g.gameState.Status,
|
|
||||||
len(g.gameState.Players),
|
|
||||||
g.camX,
|
|
||||||
myPosStr,
|
|
||||||
)
|
|
||||||
|
|
||||||
vector.DrawFilledRect(screen, 10, 10, 200, 90, color.RGBA{0, 0, 0, 180}, false)
|
|
||||||
text.Draw(screen, debugMsg, basicfont.Face7x13, 20, 30, color.White)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ASSET HELPER ---
|
// --- ASSET HELPER ---
|
||||||
@@ -476,8 +465,10 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl
|
|||||||
screenX := worldX - g.camX
|
screenX := worldX - g.camX
|
||||||
screenY := worldY
|
screenY := worldY
|
||||||
|
|
||||||
// Optimierung: Nicht zeichnen, wenn komplett außerhalb
|
// Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden)
|
||||||
if screenX < -200 || screenX > ScreenWidth+200 {
|
// Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px)
|
||||||
|
canvasW, _ := screen.Size()
|
||||||
|
if screenX < -800 || screenX > float64(canvasW)+800 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,3 +510,90 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drawDebugOverlay zeigt Performance und Network Stats (F3 zum Umschalten)
|
||||||
|
func (g *Game) drawDebugOverlay(screen *ebiten.Image) {
|
||||||
|
// Hintergrund (halbtransparent)
|
||||||
|
vector.DrawFilledRect(screen, 10, 80, 350, 170, color.RGBA{0, 0, 0, 180}, false)
|
||||||
|
vector.StrokeRect(screen, 10, 80, 350, 170, 2, color.RGBA{255, 255, 0, 255}, false)
|
||||||
|
|
||||||
|
y := 95
|
||||||
|
lineHeight := 15
|
||||||
|
|
||||||
|
// Titel
|
||||||
|
text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255})
|
||||||
|
y += lineHeight + 5
|
||||||
|
|
||||||
|
// FPS
|
||||||
|
fpsColor := color.RGBA{0, 255, 0, 255}
|
||||||
|
if g.currentFPS < 15 {
|
||||||
|
fpsColor = color.RGBA{255, 0, 0, 255}
|
||||||
|
} else if g.currentFPS < 30 {
|
||||||
|
fpsColor = color.RGBA{255, 165, 0, 255}
|
||||||
|
}
|
||||||
|
text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsColor)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
// Server Update Latenz
|
||||||
|
updateAge := time.Since(g.lastUpdateTime).Milliseconds()
|
||||||
|
latencyColor := color.RGBA{0, 255, 0, 255}
|
||||||
|
if updateAge > 200 {
|
||||||
|
latencyColor = color.RGBA{255, 0, 0, 255}
|
||||||
|
} else if updateAge > 100 {
|
||||||
|
latencyColor = color.RGBA{255, 165, 0, 255}
|
||||||
|
}
|
||||||
|
text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyColor)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
// Network Stats
|
||||||
|
text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
oooColor := color.RGBA{0, 255, 0, 255}
|
||||||
|
if g.outOfOrderCount > 10 {
|
||||||
|
oooColor = color.RGBA{255, 165, 0, 255}
|
||||||
|
}
|
||||||
|
if g.outOfOrderCount > 50 {
|
||||||
|
oooColor = color.RGBA{255, 0, 0, 255}
|
||||||
|
}
|
||||||
|
text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooColor)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
// Packet Loss Rate
|
||||||
|
if g.totalUpdates > 0 {
|
||||||
|
lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100
|
||||||
|
lossColor := color.RGBA{0, 255, 0, 255}
|
||||||
|
if lossRate > 10 {
|
||||||
|
lossColor = color.RGBA{255, 0, 0, 255}
|
||||||
|
} else if lossRate > 5 {
|
||||||
|
lossColor = color.RGBA{255, 165, 0, 255}
|
||||||
|
}
|
||||||
|
text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossColor)
|
||||||
|
y += lineHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Prediction Stats
|
||||||
|
text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
corrColor := color.RGBA{0, 255, 0, 255}
|
||||||
|
if g.correctionCount > 100 {
|
||||||
|
corrColor = color.RGBA{255, 165, 0, 255}
|
||||||
|
}
|
||||||
|
if g.correctionCount > 500 {
|
||||||
|
corrColor = color.RGBA{255, 0, 0, 255}
|
||||||
|
}
|
||||||
|
text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrColor)
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
// Current Correction Magnitude
|
||||||
|
corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY)
|
||||||
|
if corrMag > 0.1 {
|
||||||
|
text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, color.RGBA{255, 165, 0, 255})
|
||||||
|
} else {
|
||||||
|
text.Draw(screen, "Corr Mag: 0.0", basicfont.Face7x13, 20, y, color.RGBA{0, 255, 0, 255})
|
||||||
|
}
|
||||||
|
y += lineHeight
|
||||||
|
|
||||||
|
// Server Sequence
|
||||||
|
text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White)
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ func GenerateGroundTile(tileIdx int) GroundTile {
|
|||||||
Stones: make([]Stone, 0),
|
Stones: make([]Stone, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe)
|
// Zufällige Dirt-Patches generieren (20-30 pro Tile, über die ganze Höhe)
|
||||||
numDirt := 15 + rng.Intn(10)
|
numDirt := 20 + rng.Intn(10)
|
||||||
dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe
|
dirtHeight := 5000.0 // Gesamte Dirt-Höhe bis tief in die Erde
|
||||||
for i := 0; i < numDirt; i++ {
|
for i := 0; i < numDirt; i++ {
|
||||||
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
|
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
|
||||||
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
|
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
|
||||||
@@ -72,7 +72,17 @@ func GenerateGroundTile(tileIdx int) GroundTile {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keine Steine mehr auf dem Gras
|
// Steine IN der Erde generieren (10-20 pro Tile, tief verteilt)
|
||||||
|
numStones := 10 + 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
|
||||||
|
Color: color.RGBA{100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 255},
|
||||||
|
Shape: rng.Intn(2), // 0=rund, 1=eckig
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// In Cache speichern
|
// In Cache speichern
|
||||||
groundCache[tileIdx] = tile
|
groundCache[tileIdx] = tile
|
||||||
@@ -81,20 +91,25 @@ func GenerateGroundTile(tileIdx int) GroundTile {
|
|||||||
|
|
||||||
// RenderGround rendert den Boden mit Bewegung
|
// RenderGround rendert den Boden mit Bewegung
|
||||||
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
|
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
|
||||||
|
// Tatsächliche Canvas-Größe verwenden
|
||||||
|
canvasW, _ := 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)
|
floorY := float32(RefFloorY)
|
||||||
floorH := float32(ScreenHeight - RefFloorY)
|
floorH := float32(5000) // Tief in die Erde
|
||||||
|
|
||||||
// 1. Basis Gras-Schicht
|
// 1. Basis Gras-Schicht (volle Canvas-Breite, nur dünne Grasnarbe)
|
||||||
vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false)
|
vector.DrawFilledRect(screen, 0, floorY, float32(canvasW), 20, ColGrass, false)
|
||||||
|
|
||||||
// 2. Dirt-Schicht (Basis)
|
// 2. Dirt-Schicht (Basis, volle Canvas-Breite, tief nach unten)
|
||||||
vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false)
|
vector.DrawFilledRect(screen, 0, floorY+20, float32(canvasW), floorH-20, ColDirt, false)
|
||||||
|
|
||||||
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
|
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
|
||||||
// Berechne welche Tiles sichtbar sind
|
// Berechne welche Tiles sichtbar sind (basierend auf Canvas-Breite)
|
||||||
tileWidth := 128.0
|
tileWidth := 128.0
|
||||||
startTile := int(math.Floor(cameraX / tileWidth))
|
startTile := int(math.Floor(cameraX / tileWidth))
|
||||||
endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth))
|
endTile := int(math.Ceil((cameraX + float64(canvasW)) / tileWidth))
|
||||||
|
|
||||||
// Tiles rendern
|
// Tiles rendern
|
||||||
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
|
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
|
||||||
@@ -106,8 +121,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
|
|||||||
screenX := float32(worldX - cameraX)
|
screenX := float32(worldX - cameraX)
|
||||||
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
|
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
|
||||||
|
|
||||||
// Nur rendern wenn im sichtbaren Bereich
|
// Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden)
|
||||||
if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) {
|
if screenX+float32(dirt.Width) > 0 && screenX < float32(canvasW) {
|
||||||
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
|
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +133,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
|
|||||||
screenX := float32(worldX - cameraX)
|
screenX := float32(worldX - cameraX)
|
||||||
screenY := float32(RefFloorY) + float32(stone.Y)
|
screenY := float32(RefFloorY) + float32(stone.Y)
|
||||||
|
|
||||||
// Nur rendern wenn im sichtbaren Bereich
|
// Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden)
|
||||||
if screenX > -20 && screenX < float32(ScreenWidth)+20 {
|
if screenX > -20 && screenX < float32(canvasW)+20 {
|
||||||
if stone.Shape == 0 {
|
if stone.Shape == 0 {
|
||||||
// Runder Stein
|
// Runder Stein
|
||||||
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)
|
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)
|
||||||
|
|||||||
@@ -88,11 +88,14 @@ type Game struct {
|
|||||||
predictedVX float64
|
predictedVX float64
|
||||||
predictedVY float64
|
predictedVY float64
|
||||||
predictedGround bool
|
predictedGround bool
|
||||||
|
predictedOnWall bool
|
||||||
|
currentSpeed float64 // Aktuelle Scroll-Geschwindigkeit vom Server
|
||||||
inputSequence uint32 // Sequenznummer für Inputs
|
inputSequence uint32 // Sequenznummer für Inputs
|
||||||
pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs
|
pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs
|
||||||
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
|
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
|
||||||
predictionMutex sync.Mutex // Mutex für pendingInputs
|
predictionMutex sync.Mutex // Mutex für pendingInputs
|
||||||
lastRecvSeq uint32 // Letzte empfangene Server-Sequenznummer (für Out-of-Order-Erkennung)
|
lastRecvSeq uint32 // Letzte empfangene Server-Sequenznummer (für Out-of-Order-Erkennung)
|
||||||
|
lastInputTime time.Time // Letzter Input-Send (für 20 TPS Throttling)
|
||||||
|
|
||||||
// Smooth Correction
|
// Smooth Correction
|
||||||
correctionX float64 // Verbleibende Korrektur in X
|
correctionX float64 // Verbleibende Korrektur in X
|
||||||
@@ -119,6 +122,18 @@ type Game struct {
|
|||||||
joyTouchID ebiten.TouchID
|
joyTouchID ebiten.TouchID
|
||||||
btnJumpActive bool
|
btnJumpActive bool
|
||||||
keyboardUsed bool // Wurde Tastatur benutzt?
|
keyboardUsed bool // Wurde Tastatur benutzt?
|
||||||
|
|
||||||
|
// Debug Stats
|
||||||
|
showDebug bool // Debug-Overlay anzeigen (F3 zum Umschalten)
|
||||||
|
fpsCounter int // Frame-Zähler
|
||||||
|
fpsSampleTime time.Time // Letzter FPS-Sample
|
||||||
|
currentFPS float64 // Aktuelle FPS
|
||||||
|
lastUpdateTime time.Time // Letzte Server-Update Zeit
|
||||||
|
updateLatency float64 // Latenz zum letzten Update (ms)
|
||||||
|
correctionCount int // Anzahl der Korrekturen
|
||||||
|
outOfOrderCount int // Anzahl verworfener Out-of-Order Pakete
|
||||||
|
totalUpdates int // Gesamtzahl empfangener Updates
|
||||||
|
pendingInputCount int // Anzahl pending Inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGame() *Game {
|
func NewGame() *Game {
|
||||||
@@ -142,6 +157,10 @@ func NewGame() *Game {
|
|||||||
// Audio System
|
// Audio System
|
||||||
audio: NewAudioSystem(),
|
audio: NewAudioSystem(),
|
||||||
|
|
||||||
|
// Debug Stats
|
||||||
|
fpsSampleTime: time.Now(),
|
||||||
|
lastUpdateTime: time.Now(),
|
||||||
|
|
||||||
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
|
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
|
||||||
joyStickX: 150, joyStickY: ScreenHeight - 150,
|
joyStickX: 150, joyStickY: ScreenHeight - 150,
|
||||||
}
|
}
|
||||||
@@ -161,6 +180,24 @@ func NewGame() *Game {
|
|||||||
|
|
||||||
// --- UPDATE ---
|
// --- UPDATE ---
|
||||||
func (g *Game) Update() error {
|
func (g *Game) Update() error {
|
||||||
|
// FPS Tracking
|
||||||
|
g.fpsCounter++
|
||||||
|
if time.Since(g.fpsSampleTime) >= time.Second {
|
||||||
|
g.currentFPS = float64(g.fpsCounter) / time.Since(g.fpsSampleTime).Seconds()
|
||||||
|
g.fpsCounter = 0
|
||||||
|
g.fpsSampleTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug Toggle (F3)
|
||||||
|
if inpututil.IsKeyJustPressed(ebiten.KeyF3) {
|
||||||
|
g.showDebug = !g.showDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending Inputs zählen für Debug
|
||||||
|
g.predictionMutex.Lock()
|
||||||
|
g.pendingInputCount = len(g.pendingInputs)
|
||||||
|
g.predictionMutex.Unlock()
|
||||||
|
|
||||||
// Game Over Handling
|
// Game Over Handling
|
||||||
if g.appState == StateGame && g.gameState.Status == "GAMEOVER" {
|
if g.appState == StateGame && g.gameState.Status == "GAMEOVER" {
|
||||||
// Back Button (oben links) - Touch Support
|
// Back Button (oben links) - Touch Support
|
||||||
@@ -594,7 +631,10 @@ func (g *Game) DrawLobby(screen *ebiten.Image) {
|
|||||||
text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText)
|
text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight }
|
func (g *Game) Layout(w, h int) (int, int) {
|
||||||
|
// Nutze die GESAMTE Bildschirmfläche ohne Einschränkungen
|
||||||
|
return w, h
|
||||||
|
}
|
||||||
|
|
||||||
// --- HELPER ---
|
// --- HELPER ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
|
|
||||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApplyInput wendet einen Input auf den vorhergesagten Zustand an
|
// ApplyInput wendet einen Input auf den vorhergesagten Zustand an
|
||||||
|
// Nutzt die gemeinsame Physik-Engine aus pkg/physics
|
||||||
func (g *Game) ApplyInput(input InputState) {
|
func (g *Game) ApplyInput(input InputState) {
|
||||||
// Horizontale Bewegung mit analogem Joystick
|
// Horizontale Bewegung mit analogem Joystick
|
||||||
moveX := 0.0
|
moveX := 0.0
|
||||||
@@ -20,39 +21,42 @@ func (g *Game) ApplyInput(input InputState) {
|
|||||||
moveX = input.JoyX
|
moveX = input.JoyX
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geschwindigkeit skaliert mit Joystick-Intensität
|
// Physik-State vorbereiten
|
||||||
// Bewegung relativ zum Scroll (symmetrisch)
|
state := physics.PlayerPhysicsState{
|
||||||
speed := config.RunSpeed + (moveX * config.PlayerSpeed)
|
X: g.predictedX,
|
||||||
g.predictedX += speed
|
Y: g.predictedY,
|
||||||
|
VX: g.predictedVX,
|
||||||
// Gravitation
|
VY: g.predictedVY,
|
||||||
g.predictedVY += config.Gravity
|
OnGround: g.predictedGround,
|
||||||
if g.predictedVY > config.MaxFall {
|
OnWall: g.predictedOnWall,
|
||||||
g.predictedVY = config.MaxFall
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast Fall
|
// Physik-Input vorbereiten
|
||||||
if input.Down {
|
physicsInput := physics.PhysicsInput{
|
||||||
g.predictedVY = config.FastFall
|
InputX: moveX,
|
||||||
|
Jump: input.Jump,
|
||||||
|
Down: input.Down,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprung
|
// Kollisions-Checker vorbereiten
|
||||||
if input.Jump && g.predictedGround {
|
g.stateMutex.Lock()
|
||||||
g.predictedVY = -config.JumpVelocity
|
collisionChecker := &physics.ClientCollisionChecker{
|
||||||
g.predictedGround = false
|
World: g.world,
|
||||||
|
ActiveChunks: g.gameState.WorldChunks,
|
||||||
|
MovingPlatforms: g.gameState.MovingPlatforms,
|
||||||
}
|
}
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
// Vertikale Bewegung
|
// Gemeinsame Physik anwenden (1:1 wie Server)
|
||||||
g.predictedY += g.predictedVY
|
physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, collisionChecker, physics.DefaultPlayerConstants())
|
||||||
|
|
||||||
// Einfache Boden-Kollision (hardcoded für jetzt)
|
// Ergebnis zurückschreiben
|
||||||
if g.predictedY >= 540 {
|
g.predictedX = state.X
|
||||||
g.predictedY = 540
|
g.predictedY = state.Y
|
||||||
g.predictedVY = 0
|
g.predictedVX = state.VX
|
||||||
g.predictedGround = true
|
g.predictedVY = state.VY
|
||||||
} else {
|
g.predictedGround = state.OnGround
|
||||||
g.predictedGround = false
|
g.predictedOnWall = state.OnWall
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileWithServer gleicht lokale Prediction mit Server-State ab
|
// ReconcileWithServer gleicht lokale Prediction mit Server-State ab
|
||||||
@@ -70,14 +74,15 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporäre Position für Replay
|
// Temporäre Position für Replay (jetzt MIT Y-Achse)
|
||||||
replayX := serverState.X
|
replayX := serverState.X
|
||||||
replayY := serverState.Y
|
replayY := serverState.Y
|
||||||
replayVX := serverState.VX
|
replayVX := serverState.VX
|
||||||
replayVY := serverState.VY
|
replayVY := serverState.VY
|
||||||
replayGround := serverState.OnGround
|
replayGround := serverState.OnGround
|
||||||
|
replayOnWall := serverState.OnWall
|
||||||
|
|
||||||
// Replay alle noch nicht bestätigten Inputs
|
// Replay alle noch nicht bestätigten Inputs mit VOLLER Physik
|
||||||
if len(g.pendingInputs) > 0 {
|
if len(g.pendingInputs) > 0 {
|
||||||
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
|
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
|
||||||
if input, ok := g.pendingInputs[seq]; ok {
|
if input, ok := g.pendingInputs[seq]; ok {
|
||||||
@@ -85,12 +90,14 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
|||||||
oldX, oldY := g.predictedX, g.predictedY
|
oldX, oldY := g.predictedX, g.predictedY
|
||||||
oldVX, oldVY := g.predictedVX, g.predictedVY
|
oldVX, oldVY := g.predictedVX, g.predictedVY
|
||||||
oldGround := g.predictedGround
|
oldGround := g.predictedGround
|
||||||
|
oldOnWall := g.predictedOnWall
|
||||||
|
|
||||||
g.predictedX = replayX
|
g.predictedX = replayX
|
||||||
g.predictedY = replayY
|
g.predictedY = replayY
|
||||||
g.predictedVX = replayVX
|
g.predictedVX = replayVX
|
||||||
g.predictedVY = replayVY
|
g.predictedVY = replayVY
|
||||||
g.predictedGround = replayGround
|
g.predictedGround = replayGround
|
||||||
|
g.predictedOnWall = replayOnWall
|
||||||
|
|
||||||
g.ApplyInput(input)
|
g.ApplyInput(input)
|
||||||
|
|
||||||
@@ -99,6 +106,7 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
|||||||
replayVX = g.predictedVX
|
replayVX = g.predictedVX
|
||||||
replayVY = g.predictedVY
|
replayVY = g.predictedVY
|
||||||
replayGround = g.predictedGround
|
replayGround = g.predictedGround
|
||||||
|
replayOnWall = g.predictedOnWall
|
||||||
|
|
||||||
// Zurücksetzen
|
// Zurücksetzen
|
||||||
g.predictedX = oldX
|
g.predictedX = oldX
|
||||||
@@ -106,25 +114,44 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
|||||||
g.predictedVX = oldVX
|
g.predictedVX = oldVX
|
||||||
g.predictedVY = oldVY
|
g.predictedVY = oldVY
|
||||||
g.predictedGround = oldGround
|
g.predictedGround = oldGround
|
||||||
|
g.predictedOnWall = oldOnWall
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berechne Differenz zwischen aktueller Prediction und Server-Replay
|
// Berechne Differenz zwischen Client-Prediction und Server-Replay (X und Y)
|
||||||
diffX := replayX - g.predictedX
|
diffX := replayX - g.predictedX
|
||||||
diffY := replayY - g.predictedY
|
diffY := replayY - g.predictedY
|
||||||
|
dist := diffX*diffX + diffY*diffY
|
||||||
|
|
||||||
// Nur korrigieren wenn Differenz signifikant
|
// Speichere Korrektur-Magnitude für Debug
|
||||||
// Bei 20 TPS größerer Threshold wegen größerer normaler Abweichungen
|
|
||||||
const threshold = 5.0 // Erhöht für 20 TPS (war 2.0)
|
|
||||||
if diffX*diffX+diffY*diffY > threshold*threshold {
|
|
||||||
// Speichere Korrektur für sanfte Interpolation
|
|
||||||
g.correctionX = diffX
|
g.correctionX = diffX
|
||||||
g.correctionY = diffY
|
g.correctionY = diffY
|
||||||
|
|
||||||
|
// Bei sehr kleinen Abweichungen (<2px): Sofort korrigieren um Drift zu vermeiden
|
||||||
|
if dist < 4.0 { // 2px threshold
|
||||||
|
g.predictedX = replayX
|
||||||
|
g.predictedY = replayY
|
||||||
|
} else if dist > 100*100 {
|
||||||
|
// Bei sehr großen Abweichungen (>100px): Sofort korrigieren (Teleport/Respawn)
|
||||||
|
g.predictedX = replayX
|
||||||
|
g.predictedY = replayY
|
||||||
|
g.correctionCount++
|
||||||
|
} else if dist > 1.0 {
|
||||||
|
// Bei normalen Abweichungen: Sanfte Interpolation
|
||||||
|
// Bei 20 TPS: Aggressivere Interpolation
|
||||||
|
interpFactor := 0.5 // 50% pro Tick
|
||||||
|
if dist > 50*50 {
|
||||||
|
interpFactor = 0.8 // 80% bei großen Abweichungen
|
||||||
|
}
|
||||||
|
g.predictedX += diffX * interpFactor
|
||||||
|
g.predictedY += diffY * interpFactor
|
||||||
|
g.correctionCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Velocity und Ground immer sofort übernehmen
|
// Velocity und Ground Status vom Server übernehmen
|
||||||
g.predictedVX = replayVX
|
g.predictedVX = replayVX
|
||||||
g.predictedVY = replayVY
|
g.predictedVY = replayVY
|
||||||
g.predictedGround = replayGround
|
g.predictedGround = replayGround
|
||||||
|
g.predictedOnWall = replayOnWall
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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')}
|
@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}
|
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}
|
#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:0!important;left:0!important;width:100%!important;height:100%!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: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.game-active{opacity:1;pointer-events:auto;z-index:2000!important;visibility:visible}
|
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{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}
|
.overlay-screen.hidden{display:none!important}
|
||||||
|
|||||||
@@ -159,7 +159,20 @@ func main() {
|
|||||||
rooms[roomID] = room
|
rooms[roomID] = room
|
||||||
|
|
||||||
// Starte den Game-Loop (Physik)
|
// Starte den Game-Loop (Physik)
|
||||||
go room.RunLoop()
|
go func() {
|
||||||
|
room.RunLoop()
|
||||||
|
// Nach Ende des Spiels: Raum aufräumen
|
||||||
|
mu.Lock()
|
||||||
|
delete(rooms, roomID)
|
||||||
|
// Entferne auch alle Spieler-Sessions aus diesem Raum
|
||||||
|
for playerID, r := range playerSessions {
|
||||||
|
if r == room {
|
||||||
|
delete(playerSessions, playerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
log.Printf("🧹 Raum '%s' wurde aufgeräumt nach GAMEOVER", roomID)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spieler hinzufügen (ID, Name)
|
// Spieler hinzufügen (ID, Name)
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ type GameState struct {
|
|||||||
CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt
|
CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt
|
||||||
MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen
|
MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen
|
||||||
Sequence uint32 `json:"sequence"` // Sequenznummer für Out-of-Order-Erkennung
|
Sequence uint32 `json:"sequence"` // Sequenznummer für Out-of-Order-Erkennung
|
||||||
|
CurrentSpeed float64 `json:"current_speed"` // Aktuelle Scroll-Geschwindigkeit (für Client-Prediction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform
|
// MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider {
|
|||||||
Type: def.Type,
|
Type: def.Type,
|
||||||
}
|
}
|
||||||
list = append(list, c)
|
list = append(list, c)
|
||||||
fmt.Printf("✅ Collider generiert: Type=%s, Asset=%s, Pos=(%.0f,%.0f), DrawOff=(%.0f,%.0f), HitboxOff=(%.0f,%.0f)\n",
|
|
||||||
def.Type, obj.AssetID, c.Rect.OffsetX, c.Rect.OffsetY,
|
|
||||||
def.DrawOffX, def.DrawOffY, def.Hitbox.OffsetX, def.Hitbox.OffsetY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
256
pkg/physics/physics.go
Normal file
256
pkg/physics/physics.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package physics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayerPhysicsState enthält den kompletten Physik-Zustand eines Spielers
|
||||||
|
type PlayerPhysicsState struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
VX float64
|
||||||
|
VY float64
|
||||||
|
OnGround bool
|
||||||
|
OnWall bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhysicsInput enthält alle Inputs für einen Physik-Tick
|
||||||
|
type PhysicsInput struct {
|
||||||
|
InputX float64 // -1.0 bis 1.0 (Links/Rechts)
|
||||||
|
Jump bool
|
||||||
|
Down bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollisionChecker ist ein Interface für Kollisionserkennung
|
||||||
|
// Server und Client können unterschiedliche Implementierungen haben
|
||||||
|
type CollisionChecker interface {
|
||||||
|
CheckCollision(x, y, w, h float64) (hit bool, collisionType string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerConstants enthält alle Spieler-spezifischen Konstanten
|
||||||
|
type PlayerConstants struct {
|
||||||
|
DrawOffX float64
|
||||||
|
DrawOffY float64
|
||||||
|
HitboxOffX float64
|
||||||
|
HitboxOffY float64
|
||||||
|
Width float64
|
||||||
|
Height float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPlayerConstants gibt die Standard-Spieler-Konstanten zurück
|
||||||
|
// WICHTIG: Diese Werte müssen EXAKT mit assets.json übereinstimmen!
|
||||||
|
func DefaultPlayerConstants() PlayerConstants {
|
||||||
|
return PlayerConstants{
|
||||||
|
DrawOffX: -56.0, // Aus assets.json "player"
|
||||||
|
DrawOffY: -231.0, // Aus assets.json "player"
|
||||||
|
HitboxOffX: 68.0, // Aus assets.json "player" Hitbox.OffsetX
|
||||||
|
HitboxOffY: 42.0, // Aus assets.json "player" Hitbox.OffsetY
|
||||||
|
Width: 73.0,
|
||||||
|
Height: 184.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyPhysics wendet einen Physik-Tick auf den Spieler an
|
||||||
|
// Diese Funktion wird 1:1 von Server und Client verwendet
|
||||||
|
func ApplyPhysics(
|
||||||
|
state *PlayerPhysicsState,
|
||||||
|
input PhysicsInput,
|
||||||
|
currentSpeed float64,
|
||||||
|
collisionChecker CollisionChecker,
|
||||||
|
playerConst PlayerConstants,
|
||||||
|
) {
|
||||||
|
// --- HORIZONTALE BEWEGUNG MIT KOLLISION ---
|
||||||
|
playerMovement := input.InputX * config.PlayerSpeed
|
||||||
|
speed := currentSpeed + playerMovement
|
||||||
|
nextX := state.X + speed
|
||||||
|
|
||||||
|
// Horizontale Kollisionsprüfung
|
||||||
|
checkX := nextX + playerConst.DrawOffX + playerConst.HitboxOffX
|
||||||
|
checkY := state.Y + playerConst.DrawOffY + playerConst.HitboxOffY
|
||||||
|
|
||||||
|
xHit, xCollisionType := collisionChecker.CheckCollision(
|
||||||
|
checkX,
|
||||||
|
checkY,
|
||||||
|
playerConst.Width,
|
||||||
|
playerConst.Height,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nur X-Bewegung blockieren wenn es eine Wand/Plattform ist (nicht Obstacle)
|
||||||
|
if xHit && (xCollisionType == "wall" || xCollisionType == "platform") {
|
||||||
|
// Kollision in X-Richtung -> X nicht ändern
|
||||||
|
// state.X bleibt wie es ist
|
||||||
|
} else {
|
||||||
|
// Keine Kollision -> X-Bewegung durchführen
|
||||||
|
state.X = nextX
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VERTIKALE BEWEGUNG ---
|
||||||
|
// An der Wand: Reduzierte Gravität + Klettern
|
||||||
|
if state.OnWall {
|
||||||
|
state.VY += config.Gravity * 0.3 // 30% Gravität an der Wand
|
||||||
|
if state.VY > config.WallSlideMax {
|
||||||
|
state.VY = config.WallSlideMax
|
||||||
|
}
|
||||||
|
// Hochklettern wenn Bewegung vorhanden
|
||||||
|
if input.InputX != 0 {
|
||||||
|
state.VY = -config.WallClimbSpeed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal: Volle Gravität
|
||||||
|
state.VY += config.Gravity
|
||||||
|
if state.VY > config.MaxFall {
|
||||||
|
state.VY = config.MaxFall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast Fall
|
||||||
|
if input.Down {
|
||||||
|
state.VY = config.FastFall
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprung
|
||||||
|
if input.Jump && state.OnGround {
|
||||||
|
state.VY = -config.JumpVelocity
|
||||||
|
state.OnGround = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertikale Bewegung mit Kollisionserkennung
|
||||||
|
nextY := state.Y + state.VY
|
||||||
|
|
||||||
|
// Kollision für AKTUELLE Position prüfen (um OnGround richtig zu setzen)
|
||||||
|
checkX = state.X + playerConst.DrawOffX + playerConst.HitboxOffX
|
||||||
|
currentCheckY := state.Y + playerConst.DrawOffY + playerConst.HitboxOffY
|
||||||
|
|
||||||
|
currentHit, currentType := collisionChecker.CheckCollision(
|
||||||
|
checkX,
|
||||||
|
currentCheckY,
|
||||||
|
playerConst.Width,
|
||||||
|
playerConst.Height,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wenn Spieler aktuell bereits auf dem Boden steht
|
||||||
|
if currentHit && currentType == "platform" && state.VY >= 0 {
|
||||||
|
state.OnGround = true
|
||||||
|
state.VY = 0
|
||||||
|
state.OnWall = false
|
||||||
|
// Y-Position bleibt wo sie ist
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kollision für NÄCHSTE Position prüfen
|
||||||
|
nextCheckY := nextY + playerConst.DrawOffY + playerConst.HitboxOffY
|
||||||
|
|
||||||
|
hit, collisionType := collisionChecker.CheckCollision(
|
||||||
|
checkX,
|
||||||
|
nextCheckY,
|
||||||
|
playerConst.Width,
|
||||||
|
playerConst.Height,
|
||||||
|
)
|
||||||
|
|
||||||
|
if hit {
|
||||||
|
if collisionType == "wall" {
|
||||||
|
// An der Wand: Position halten (Y nicht ändern)
|
||||||
|
state.VY = 0
|
||||||
|
state.OnWall = true
|
||||||
|
state.OnGround = false
|
||||||
|
} else if collisionType == "obstacle" {
|
||||||
|
// Obstacle: Spieler bewegt sich in Obstacle (Server killt dann)
|
||||||
|
state.Y = nextY
|
||||||
|
state.VY = 0
|
||||||
|
state.OnGround = false
|
||||||
|
state.OnWall = false
|
||||||
|
} else if collisionType == "platform" {
|
||||||
|
// Platform: Blockiert vertikale Bewegung
|
||||||
|
if state.VY > 0 {
|
||||||
|
state.OnGround = true
|
||||||
|
}
|
||||||
|
state.VY = 0
|
||||||
|
state.OnWall = false
|
||||||
|
// Y-Position bleibt unverändert
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keine Kollision: Bewegung durchführen
|
||||||
|
state.Y = nextY
|
||||||
|
state.OnGround = false
|
||||||
|
state.OnWall = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCollisionChecker implementiert CollisionChecker für den Client
|
||||||
|
type ClientCollisionChecker struct {
|
||||||
|
World *game.World
|
||||||
|
ActiveChunks []game.ActiveChunk
|
||||||
|
MovingPlatforms []game.MovingPlatformSync
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, string) {
|
||||||
|
playerRect := game.Rect{
|
||||||
|
OffsetX: x,
|
||||||
|
OffsetY: y,
|
||||||
|
W: w,
|
||||||
|
H: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. Basis-Boden-Collider (wie Server) - UNTER dem sichtbaren Gras bis tief in die Erde
|
||||||
|
floorCollider := game.Rect{
|
||||||
|
OffsetX: -10000,
|
||||||
|
OffsetY: 540, // Startet bei Y=540 (Gras-Oberfläche)
|
||||||
|
W: 100000000,
|
||||||
|
H: 5000, // Geht tief nach UNTEN (bis Y=5540) - gesamte Erdschicht
|
||||||
|
}
|
||||||
|
if game.CheckRectCollision(playerRect, floorCollider) {
|
||||||
|
return true, "platform"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Statische Colliders aus Chunks
|
||||||
|
for _, activeChunk := range c.ActiveChunks {
|
||||||
|
if chunk, ok := c.World.ChunkLibrary[activeChunk.ChunkID]; ok {
|
||||||
|
for _, obj := range chunk.Objects {
|
||||||
|
if assetDef, ok := c.World.Manifest.Assets[obj.AssetID]; ok {
|
||||||
|
if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 {
|
||||||
|
colliderRect := game.Rect{
|
||||||
|
OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
|
||||||
|
OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
|
||||||
|
W: assetDef.Hitbox.W,
|
||||||
|
H: assetDef.Hitbox.H,
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.CheckRectCollision(playerRect, colliderRect) {
|
||||||
|
return true, assetDef.Hitbox.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bewegende Plattformen
|
||||||
|
for _, mp := range c.MovingPlatforms {
|
||||||
|
if assetDef, ok := c.World.Manifest.Assets[mp.AssetID]; ok {
|
||||||
|
if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 {
|
||||||
|
mpRect := game.Rect{
|
||||||
|
OffsetX: mp.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
|
||||||
|
OffsetY: mp.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
|
||||||
|
W: assetDef.Hitbox.W,
|
||||||
|
H: assetDef.Hitbox.H,
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.CheckRectCollision(playerRect, mpRect) {
|
||||||
|
return true, "platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerCollisionChecker ist ein Wrapper für die Server CheckCollision-Methode
|
||||||
|
type ServerCollisionChecker struct {
|
||||||
|
CheckCollisionFunc func(x, y, w, h float64) (bool, string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServerCollisionChecker) CheckCollision(x, y, w, h float64) (bool, string) {
|
||||||
|
return s.CheckCollisionFunc(x, y, w, h)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
|
||||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ type ServerPlayer struct {
|
|||||||
OnWall bool // Ist an einer Wand
|
OnWall bool // Ist an einer Wand
|
||||||
OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
|
OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
|
||||||
InputX float64 // -1 (Links), 0, 1 (Rechts)
|
InputX float64 // -1 (Links), 0, 1 (Rechts)
|
||||||
|
InputJump bool // Sprung-Input (für Physik-Engine)
|
||||||
|
InputDown bool // Nach-Unten-Input (für Fast Fall)
|
||||||
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
|
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
|
||||||
Score int
|
Score int
|
||||||
DistanceScore int // Score basierend auf zurückgelegter Distanz
|
DistanceScore int // Score basierend auf zurückgelegter Distanz
|
||||||
@@ -122,6 +125,8 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
|
|||||||
r.pDrawOffY = def.DrawOffY
|
r.pDrawOffY = def.DrawOffY
|
||||||
r.pHitboxOffX = def.Hitbox.OffsetX
|
r.pHitboxOffX = def.Hitbox.OffsetX
|
||||||
r.pHitboxOffY = def.Hitbox.OffsetY
|
r.pHitboxOffY = def.Hitbox.OffsetY
|
||||||
|
log.Printf("🎮 Player Hitbox geladen: DrawOff=(%.1f, %.1f), HitboxOff=(%.1f, %.1f), Size=(%.1f, %.1f)",
|
||||||
|
r.pDrawOffX, r.pDrawOffY, r.pHitboxOffX, r.pHitboxOffY, r.pW, r.pH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start-Chunk
|
// Start-Chunk
|
||||||
@@ -163,7 +168,7 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
|
|||||||
// --- MAIN LOOP ---
|
// --- MAIN LOOP ---
|
||||||
|
|
||||||
func (r *Room) RunLoop() {
|
func (r *Room) RunLoop() {
|
||||||
// 60 Tick pro Sekunde
|
// 20 Tick pro Sekunde
|
||||||
ticker := time.NewTicker(time.Second / 20)
|
ticker := time.NewTicker(time.Second / 20)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -172,6 +177,17 @@ func (r *Room) RunLoop() {
|
|||||||
case <-r.stopChan:
|
case <-r.stopChan:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
r.Mutex.RLock()
|
||||||
|
status := r.Status
|
||||||
|
r.Mutex.RUnlock()
|
||||||
|
|
||||||
|
// Stoppe Updates wenn Spiel vorbei ist
|
||||||
|
if status == "GAMEOVER" {
|
||||||
|
r.Broadcast() // Ein letztes Mal broadcasten
|
||||||
|
time.Sleep(5 * time.Second) // Kurz warten damit Clients den GAMEOVER State sehen
|
||||||
|
return // Beende Loop
|
||||||
|
}
|
||||||
|
|
||||||
r.Update()
|
r.Update()
|
||||||
r.Broadcast()
|
r.Broadcast()
|
||||||
}
|
}
|
||||||
@@ -196,7 +212,7 @@ func (r *Room) AddPlayer(id, name string) {
|
|||||||
ID: id,
|
ID: id,
|
||||||
Name: name,
|
Name: name,
|
||||||
X: spawnX,
|
X: spawnX,
|
||||||
Y: 200,
|
Y: 400, // Spawn über dem Gras (Y=540), fällt dann auf den Boden
|
||||||
OnGround: false,
|
OnGround: false,
|
||||||
Score: 0,
|
Score: 0,
|
||||||
IsAlive: true,
|
IsAlive: true,
|
||||||
@@ -261,18 +277,15 @@ func (r *Room) HandleInput(input game.ClientInput) {
|
|||||||
|
|
||||||
switch input.Type {
|
switch input.Type {
|
||||||
case "JUMP":
|
case "JUMP":
|
||||||
if p.OnGround {
|
p.InputJump = true // Setze Jump-Flag für Physik-Engine
|
||||||
p.VY = -config.JumpVelocity
|
// Double Jump spezial-Logik (außerhalb der Physik-Engine)
|
||||||
p.OnGround = false
|
if !p.OnGround && p.HasDoubleJump && !p.DoubleJumpUsed {
|
||||||
p.DoubleJumpUsed = false // Reset double jump on ground jump
|
|
||||||
} else if p.HasDoubleJump && !p.DoubleJumpUsed {
|
|
||||||
// Double Jump in der Luft
|
|
||||||
p.VY = -config.JumpVelocity
|
p.VY = -config.JumpVelocity
|
||||||
p.DoubleJumpUsed = true
|
p.DoubleJumpUsed = true
|
||||||
log.Printf("⚡ %s verwendet Double Jump!", p.Name)
|
log.Printf("⚡ %s verwendet Double Jump!", p.Name)
|
||||||
}
|
}
|
||||||
case "DOWN":
|
case "DOWN":
|
||||||
p.VY = config.FastFall
|
p.InputDown = true // Setze Down-Flag für Fast Fall
|
||||||
case "LEFT_DOWN":
|
case "LEFT_DOWN":
|
||||||
p.InputX = -1
|
p.InputX = -1
|
||||||
case "LEFT_UP":
|
case "LEFT_UP":
|
||||||
@@ -355,44 +368,67 @@ func (r *Room) Update() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// X Bewegung
|
// === PHYSIK MIT GEMEINSAMER ENGINE (1:1 wie Client) ===
|
||||||
// Spieler bewegt sich relativ zum Scroll
|
|
||||||
// Scroll-Geschwindigkeit + Links/Rechts Bewegung
|
|
||||||
playerMovement := p.InputX * config.PlayerSpeed
|
|
||||||
currentSpeed := r.CurrentSpeed + playerMovement
|
|
||||||
nextX := p.X + currentSpeed
|
|
||||||
|
|
||||||
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
// Physik-State vorbereiten
|
||||||
if hitX {
|
state := physics.PlayerPhysicsState{
|
||||||
if typeX == "wall" {
|
X: p.X,
|
||||||
// Wand getroffen - kann klettern!
|
Y: p.Y,
|
||||||
p.OnWall = true
|
VX: p.VX,
|
||||||
// X-Position NICHT ändern (bleibt vor der Wand stehen)
|
VY: p.VY,
|
||||||
} else if typeX == "obstacle" {
|
OnGround: p.OnGround,
|
||||||
// Godmode prüfen
|
OnWall: p.OnWall,
|
||||||
if p.HasGodMode && time.Now().Before(p.GodModeEndTime) {
|
}
|
||||||
// Mit Godmode - Obstacle wird zerstört, Spieler überlebt
|
|
||||||
p.X = nextX
|
// Physik-Input vorbereiten
|
||||||
// TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle)
|
physicsInput := physics.PhysicsInput{
|
||||||
log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name)
|
InputX: p.InputX,
|
||||||
} else {
|
Jump: p.InputJump,
|
||||||
// Ohne Godmode - Spieler stirbt
|
Down: p.InputDown,
|
||||||
p.X = nextX
|
}
|
||||||
|
|
||||||
|
// Kollisions-Checker vorbereiten
|
||||||
|
collisionChecker := &physics.ServerCollisionChecker{
|
||||||
|
CheckCollisionFunc: r.CheckCollision,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemeinsame Physik anwenden (1:1 wie Client!)
|
||||||
|
physics.ApplyPhysics(&state, physicsInput, r.CurrentSpeed, collisionChecker, physics.DefaultPlayerConstants())
|
||||||
|
|
||||||
|
// Ergebnis zurückschreiben
|
||||||
|
p.X = state.X
|
||||||
|
p.Y = state.Y
|
||||||
|
p.VX = state.VX
|
||||||
|
p.VY = state.VY
|
||||||
|
p.OnGround = state.OnGround
|
||||||
|
p.OnWall = state.OnWall
|
||||||
|
|
||||||
|
// Input-Flags zurücksetzen für nächsten Tick
|
||||||
|
p.InputJump = false
|
||||||
|
p.InputDown = false
|
||||||
|
|
||||||
|
// Double Jump Reset wenn wieder am Boden
|
||||||
|
if p.OnGround {
|
||||||
|
p.DoubleJumpUsed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SERVER-SPEZIFISCHE LOGIK ===
|
||||||
|
|
||||||
|
// Obstacle-Kollision prüfen -> Spieler töten
|
||||||
|
hitObstacle, obstacleType := r.CheckCollision(
|
||||||
|
p.X+r.pDrawOffX+r.pHitboxOffX,
|
||||||
|
p.Y+r.pDrawOffY+r.pHitboxOffY,
|
||||||
|
r.pW,
|
||||||
|
r.pH,
|
||||||
|
)
|
||||||
|
if hitObstacle && obstacleType == "obstacle" {
|
||||||
r.KillPlayer(p)
|
r.KillPlayer(p)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Platform blockiert
|
|
||||||
p.OnWall = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.X = nextX
|
|
||||||
p.OnWall = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grenzen
|
// Grenzen
|
||||||
if p.X > r.GlobalScrollX+1200 {
|
if p.X > r.GlobalScrollX+2000 {
|
||||||
p.X = r.GlobalScrollX + 1200
|
p.X = r.GlobalScrollX + 2000
|
||||||
} // Rechts Block
|
} // Rechts Block
|
||||||
if p.X < r.GlobalScrollX-50 {
|
if p.X < r.GlobalScrollX-50 {
|
||||||
r.KillPlayer(p)
|
r.KillPlayer(p)
|
||||||
@@ -403,53 +439,11 @@ func (r *Room) Update() {
|
|||||||
maxX = p.X
|
maxX = p.X
|
||||||
}
|
}
|
||||||
|
|
||||||
// Y Bewegung
|
// Prüfe ob auf bewegender Plattform (für Platform-Mitbewegung)
|
||||||
// An der Wand: Reduzierte Gravität + Klettern mit InputX
|
if p.OnGround {
|
||||||
if p.OnWall {
|
platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||||||
// Wandrutschen (langsame Fallgeschwindigkeit)
|
|
||||||
p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand
|
|
||||||
if p.VY > config.WallSlideMax {
|
|
||||||
p.VY = config.WallSlideMax
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung)
|
|
||||||
if p.InputX != 0 {
|
|
||||||
p.VY = -config.WallClimbSpeed
|
|
||||||
}
|
|
||||||
} 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 == "wall" {
|
|
||||||
// An der Wand: Nicht töten, Position halten und klettern ermöglichen
|
|
||||||
p.VY = 0
|
|
||||||
p.OnWall = true
|
|
||||||
} 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.OnMovingPlatform = platform
|
||||||
}
|
|
||||||
p.VY = 0
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
p.Y += p.VY
|
|
||||||
p.OnGround = false
|
|
||||||
p.OnMovingPlatform = nil
|
p.OnMovingPlatform = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,10 +529,6 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
|
|||||||
// 1. Statische Colliders (Chunks)
|
// 1. Statische Colliders (Chunks)
|
||||||
for _, c := range r.Colliders {
|
for _, c := range r.Colliders {
|
||||||
if game.CheckRectCollision(playerRect, c.Rect) {
|
if game.CheckRectCollision(playerRect, c.Rect) {
|
||||||
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)",
|
|
||||||
c.Type,
|
|
||||||
playerRect.OffsetX, playerRect.OffsetY, playerRect.W, playerRect.H,
|
|
||||||
c.Rect.OffsetX, c.Rect.OffsetY, c.Rect.W, c.Rect.H)
|
|
||||||
return true, c.Type
|
return true, c.Type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,6 +792,7 @@ func (r *Room) Broadcast() {
|
|||||||
CollectedPowerups: r.CollectedPowerups,
|
CollectedPowerups: r.CollectedPowerups,
|
||||||
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
|
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
|
||||||
Sequence: r.sequence,
|
Sequence: r.sequence,
|
||||||
|
CurrentSpeed: r.CurrentSpeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, p := range r.Players {
|
for id, p := range r.Players {
|
||||||
|
|||||||
Reference in New Issue
Block a user