Private
Public Access
1
0

Implement core game functionalities: client prediction, coin collection, scoring, game state synchronization, and player management.

This commit is contained in:
Sebastian Unterschütz
2026-01-01 16:46:39 +01:00
parent 4b2995846e
commit 5e6b8a2304
9 changed files with 902 additions and 203 deletions

View File

@@ -7,8 +7,11 @@ import (
_ "image/png"
"io/ioutil"
"log"
"math/rand"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
@@ -28,7 +31,8 @@ const (
ScreenWidth = 1280
ScreenHeight = 720
StateMenu = 0
StateGame = 1
StateLobby = 1
StateGame = 2
RefFloorY = 540
)
@@ -41,6 +45,15 @@ var (
ColDirt = color.RGBA{101, 67, 33, 255}
)
// InputState speichert einen einzelnen Input für Replay
type InputState struct {
Sequence uint32
Left bool
Right bool
Jump bool
Down bool
}
// --- GAME STRUCT ---
type Game struct {
appState int
@@ -53,8 +66,24 @@ type Game struct {
// Spieler Info
playerName string
roomID string // <-- NEU: Raum ID
roomID string
activeField string // "name" oder "room"
gameMode string // "solo" oder "coop"
isHost bool
// Lobby State (für Change Detection)
lastPlayerCount int
lastStatus string
// Client Prediction
predictedX float64 // Vorhergesagte Position
predictedY float64
predictedVX float64
predictedVY float64
predictedGround bool
inputSequence uint32 // Sequenznummer für Inputs
pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
// Kamera
camX float64
@@ -74,9 +103,10 @@ func NewGame() *Game {
assetsImages: make(map[string]*ebiten.Image),
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
playerName: "Student",
roomID: "room1", // Standard Raum
activeField: "name",
playerName: "Student",
activeField: "name",
gameMode: "",
pendingInputs: make(map[uint32]InputState),
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150,
@@ -102,109 +132,336 @@ func (g *Game) loadAssets() {
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
}
// Chunks laden
chunkDir := filepath.Join(baseDir, "chunks")
err = g.world.LoadChunkLibrary(chunkDir)
if err != nil {
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
} else {
fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge")
// DEBUG: Details der geladenen Chunks
for id, chunk := range g.world.ChunkLibrary {
fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects))
}
}
// Bilder vorladen
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
// log.Println("Fehler beim Laden von Bild:", def.Filename)
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}
// --- UPDATE ---
func (g *Game) Update() error {
switch g.appState {
case StateMenu:
g.handleMenuInput() // Text Eingabe Logik
// Button & Felder Layout
btnW, btnH := 200, 50
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight/2 + 80
// Feld 1: Name
fieldW, fieldH := 250, 40
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 100
// Feld 2: Raum (NEU)
roomX := ScreenWidth/2 - fieldW/2
roomY := ScreenHeight/2 - 20
// Klick Checks (Maus & Touch)
if isHit(nameX, nameY, fieldW, fieldH) {
g.activeField = "name"
} else if isHit(roomX, roomY, fieldW, fieldH) {
g.activeField = "room"
} else if isHit(btnX, btnY, btnW, btnH) {
// START
if g.playerName == "" {
g.playerName = "Player"
}
if g.roomID == "" {
g.roomID = "room1"
}
g.appState = StateGame
go g.connectAndStart()
} else if isHit(0, 0, ScreenWidth, ScreenHeight) {
// Klick ins Leere -> Fokus weg
g.activeField = ""
}
g.updateMenu()
case StateLobby:
g.updateLobby()
case StateGame:
g.UpdateGame() // In game_render.go
g.UpdateGame()
}
return nil
}
func (g *Game) updateMenu() {
g.handleMenuInput()
// Name-Feld
fieldW, fieldH := 250, 40
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 150
if isHit(nameX, nameY, fieldW, fieldH) {
g.activeField = "name"
return
}
// Mode-Buttons
btnW, btnH := 200, 60
soloX := ScreenWidth/2 - btnW - 20
coopX := ScreenWidth/2 + 20
btnY := ScreenHeight/2 - 20
if isHit(soloX, btnY, btnW, btnH) {
// SOLO MODE
if g.playerName == "" {
g.playerName = "Player"
}
g.gameMode = "solo"
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true
g.appState = StateGame
go g.connectAndStart()
} else if isHit(coopX, btnY, btnW, btnH) {
// CO-OP MODE
if g.playerName == "" {
g.playerName = "Player"
}
g.gameMode = "coop"
g.roomID = generateRoomCode()
g.isHost = true
g.appState = StateLobby
go g.connectAndStart()
}
// Join Button (unten)
joinW, joinH := 300, 50
joinX := ScreenWidth/2 - joinW/2
joinY := ScreenHeight/2 + 100
if isHit(joinX, joinY, joinW, joinH) {
g.activeField = "room"
}
// Join Code Feld
if g.activeField == "room" {
roomFieldW := 200
roomFieldX := ScreenWidth/2 - roomFieldW/2
roomFieldY := ScreenHeight/2 + 160
if isHit(roomFieldX, roomFieldY, roomFieldW, 40) {
// Stay in room field
} else if isHit(roomFieldX+roomFieldW+20, roomFieldY, 100, 40) {
// JOIN button next to code field
if g.roomID != "" && g.playerName != "" {
g.gameMode = "coop"
g.isHost = false
g.appState = StateLobby
go g.connectAndStart()
}
}
}
}
func (g *Game) updateLobby() {
// Start Button (nur für Host)
if g.isHost {
btnW, btnH := 200, 60
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight - 150
if isHit(btnX, btnY, btnW, btnH) {
// START GAME
g.SendCommand("START")
}
}
// Zurück Button
backW, backH := 100, 40
if isHit(50, 50, backW, backH) {
if g.conn != nil {
g.conn.Close()
}
g.appState = StateMenu
g.connected = false
g.gameState = game.GameState{Players: make(map[string]game.PlayerState)}
}
// Spiel wurde gestartet?
if g.gameState.Status == "COUNTDOWN" || g.gameState.Status == "RUNNING" {
g.appState = StateGame
}
}
// --- DRAW ---
func (g *Game) Draw(screen *ebiten.Image) {
switch g.appState {
case StateMenu:
g.DrawMenu(screen)
case StateLobby:
g.DrawLobby(screen)
case StateGame:
g.DrawGame(screen) // In game_render.go
g.DrawGame(screen)
}
}
func (g *Game) DrawMenu(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
text.Draw(screen, "ESCAPE FROM TEACHER", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-140, ColText)
// Helper zum Zeichnen von Textfeldern
drawField := func(label, value, fieldID string, x, y, w, h int) {
col := color.RGBA{50, 50, 60, 255}
if g.activeField == fieldID {
col = color.RGBA{70, 70, 80, 255}
}
vector.DrawFilledRect(screen, float32(x), float32(y), float32(w), float32(h), col, false)
vector.StrokeRect(screen, float32(x), float32(y), float32(w), float32(h), 1, color.White, false)
// Titel
title := "ESCAPE FROM TEACHER"
text.Draw(screen, title, basicfont.Face7x13, ScreenWidth/2-80, 100, ColText)
display := value
if g.activeField == fieldID && (time.Now().UnixMilli()/500)%2 == 0 {
// Name-Feld
fieldW := 250
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 150
col := color.RGBA{50, 50, 60, 255}
if g.activeField == "name" {
col = color.RGBA{70, 70, 80, 255}
}
vector.DrawFilledRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, col, false)
vector.StrokeRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, 1, color.White, false)
display := g.playerName
if g.activeField == "name" && (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
text.Draw(screen, "Name: "+display, basicfont.Face7x13, nameX+10, nameY+25, ColText)
// Mode Selection
text.Draw(screen, "Select Game Mode:", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-60, ColText)
// SOLO Button
btnW, btnH := 200, 60
soloX := ScreenWidth/2 - btnW - 20
btnY := ScreenHeight/2 - 20
vector.DrawFilledRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "SOLO", basicfont.Face7x13, soloX+80, btnY+35, ColText)
// CO-OP Button
coopX := ScreenWidth/2 + 20
vector.DrawFilledRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "CO-OP (Host)", basicfont.Face7x13, coopX+45, btnY+35, ColText)
// Join Section
joinY := ScreenHeight/2 + 100
text.Draw(screen, "Or join a room:", basicfont.Face7x13, ScreenWidth/2-60, joinY, color.Gray{200})
if g.activeField == "room" {
roomFieldW := 200
roomFieldX := ScreenWidth/2 - roomFieldW/2
roomFieldY := ScreenHeight/2 + 160
col := color.RGBA{70, 70, 80, 255}
vector.DrawFilledRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, col, false)
vector.StrokeRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, 1, color.White, false)
display := g.roomID
if (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
text.Draw(screen, label+": "+display, basicfont.Face7x13, x+10, y+25, ColText)
text.Draw(screen, display, basicfont.Face7x13, roomFieldX+10, roomFieldY+25, ColText)
// Join Button
joinBtnX := roomFieldX + roomFieldW + 20
vector.DrawFilledRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, color.RGBA{0, 150, 0, 255}, false)
vector.StrokeRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, 2, color.White, false)
text.Draw(screen, "JOIN", basicfont.Face7x13, joinBtnX+30, roomFieldY+25, ColText)
} else {
joinBtnW := 300
joinBtnX := ScreenWidth/2 - joinBtnW/2
joinBtnY := ScreenHeight/2 + 120
vector.DrawFilledRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, ColBtnNormal, false)
vector.StrokeRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, 2, color.White, false)
text.Draw(screen, "Join with Code", basicfont.Face7x13, joinBtnX+90, joinBtnY+30, ColText)
}
fieldW := 250
drawField("Name", g.playerName, "name", ScreenWidth/2-fieldW/2, ScreenHeight/2-100, fieldW, 40)
drawField("Room Code", g.roomID, "room", ScreenWidth/2-fieldW/2, ScreenHeight/2-20, fieldW, 40)
text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150})
}
// Start Button
btnW, btnH := 200, 50
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight/2 + 80
vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "JOIN GAME", basicfont.Face7x13, btnX+65, btnY+30, ColText)
func (g *Game) DrawLobby(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
text.Draw(screen, "WASD / Arrows to Move - SPACE to Jump\nMobile: Touch Controls", basicfont.Face7x13, ScreenWidth/2-120, ScreenHeight-50, color.Gray{150})
// Titel
text.Draw(screen, "LOBBY", basicfont.Face7x13, ScreenWidth/2-20, 80, ColText)
// Room Code (groß anzeigen)
text.Draw(screen, "Room Code:", basicfont.Face7x13, ScreenWidth/2-40, 150, color.Gray{200})
codeBoxW, codeBoxH := 300, 60
codeBoxX := ScreenWidth/2 - codeBoxW/2
codeBoxY := 170
vector.DrawFilledRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), color.RGBA{50, 50, 60, 255}, false)
vector.StrokeRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), 3, color.RGBA{100, 200, 100, 255}, false)
text.Draw(screen, g.roomID, basicfont.Face7x13, codeBoxX+100, codeBoxY+35, color.RGBA{100, 255, 100, 255})
// Spieler-Liste
g.stateMutex.Lock()
playerCount := len(g.gameState.Players)
// Spieler in sortierte Liste konvertieren (damit sie nicht flackern)
type PlayerEntry struct {
ID string
Name string
IsHost bool
}
players := make([]PlayerEntry, 0, playerCount)
hostID := g.gameState.HostID
for id, p := range g.gameState.Players {
name := p.Name
if name == "" {
name = id
}
isHost := (id == hostID)
players = append(players, PlayerEntry{
ID: id,
Name: name,
IsHost: isHost,
})
}
g.stateMutex.Unlock()
// Sortieren: Host zuerst, dann alphabetisch nach Name
sort.SliceStable(players, func(i, j int) bool {
if players[i].IsHost {
return true
}
if players[j].IsHost {
return false
}
return players[i].Name < players[j].Name
})
text.Draw(screen, fmt.Sprintf("Players (%d/16):", playerCount), basicfont.Face7x13, 100, 280, ColText)
y := 310
for _, p := range players {
name := p.Name
// Host markieren
if p.IsHost {
name += " [HOST]"
}
text.Draw(screen, "• "+name, basicfont.Face7x13, 120, y, ColText)
y += 25
if y > ScreenHeight-200 {
break
}
}
// Status
statusY := ScreenHeight - 180
statusText := "Waiting for host to start..."
statusCol := color.RGBA{200, 200, 0, 255}
if g.gameState.Status == "COUNTDOWN" {
statusText = fmt.Sprintf("Starting in %d...", g.gameState.TimeLeft)
statusCol = color.RGBA{255, 150, 0, 255}
}
text.Draw(screen, statusText, basicfont.Face7x13, ScreenWidth/2-80, statusY, statusCol)
// Start Button (nur für Host)
if g.isHost && g.gameState.Status == "LOBBY" {
btnW, btnH := 200, 60
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight - 150
vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), color.RGBA{0, 180, 0, 255}, false)
vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "START GAME", basicfont.Face7x13, btnX+60, btnY+35, ColText)
}
// Zurück Button
vector.DrawFilledRect(screen, 50, 50, 100, 40, color.RGBA{150, 0, 0, 255}, false)
vector.StrokeRect(screen, 50, 50, 100, 40, 2, color.White, false)
text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText)
}
func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight }
@@ -236,11 +493,14 @@ func (g *Game) handleMenuInput() {
var target *string
if g.activeField == "name" {
target = &g.playerName
}
if g.activeField == "room" {
} else if g.activeField == "room" {
target = &g.roomID
}
if target == nil {
return
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
@@ -248,10 +508,25 @@ func (g *Game) handleMenuInput() {
*target = (*target)[:len(*target)-1]
}
} else {
*target += string(ebiten.InputChars())
// Für Room Code: Nur Großbuchstaben und Zahlen
chars := string(ebiten.InputChars())
if g.activeField == "room" {
chars = strings.ToUpper(chars)
}
*target += chars
}
}
func generateRoomCode() string {
rand.Seed(time.Now().UnixNano())
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
code := make([]byte, 6)
for i := range code {
code[i] = chars[rand.Intn(len(chars))]
}
return string(code)
}
func (g *Game) connectAndStart() {
// URL: Wasm -> WS, Desktop -> TCP
serverURL := "nats://localhost:4222"
@@ -267,11 +542,30 @@ func (g *Game) connectAndStart() {
ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
g.conn = ec
sub, err := g.conn.Subscribe("game.update", func(state *game.GameState) {
// Subscribe nur auf Updates für DIESEN Raum
roomChannel := fmt.Sprintf("game.update.%s", g.roomID)
log.Printf("👂 Lausche auf Channel: %s", roomChannel)
sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
// Reconcile mit Server-State (verwendet keinen stateMutex)
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
oldPlayerCount := len(g.gameState.Players)
oldStatus := g.gameState.Status
g.gameState = *state
g.stateMutex.Unlock()
log.Printf("📦 State empfangen: Players=%d, Chunks=%d, Status=%s", len(state.Players), len(state.WorldChunks), state.Status)
// Nur bei Änderungen loggen
if len(state.Players) != oldPlayerCount || state.Status != oldStatus {
log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, state.Status)
}
})
if err != nil {
@@ -295,6 +589,14 @@ func (g *Game) connectAndStart() {
return
}
g.connected = true
// Initiale predicted Position
g.predictedX = 100
g.predictedY = 200
g.predictedVX = 0
g.predictedVY = 0
g.predictedGround = false
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...")
}
@@ -302,26 +604,79 @@ func (g *Game) SendCommand(cmdType string) {
if !g.connected {
return
}
// ID Suche (Fallback Name)
myID := ""
g.stateMutex.Lock()
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
myID = id
break
}
}
g.stateMutex.Unlock()
if myID == "" {
myID = g.playerName
myID := g.getMyPlayerID()
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
}
func (g *Game) SendInputWithSequence(input InputState) {
if !g.connected {
return
}
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
myID := g.getMyPlayerID()
// Inputs als einzelne Commands senden
if input.Left {
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "LEFT_DOWN",
Sequence: input.Sequence,
})
}
if input.Right {
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "RIGHT_DOWN",
Sequence: input.Sequence,
})
}
if input.Jump {
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "JUMP",
Sequence: input.Sequence,
})
}
if input.Down {
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "DOWN",
Sequence: input.Sequence,
})
}
// Wenn weder Links noch Rechts, sende STOP
if !input.Left && !input.Right {
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "LEFT_UP",
Sequence: input.Sequence,
})
g.conn.Publish("game.input", game.ClientInput{
PlayerID: myID,
Type: "RIGHT_UP",
Sequence: input.Sequence,
})
}
}
func (g *Game) getMyPlayerID() string {
g.stateMutex.Lock()
defer g.stateMutex.Unlock()
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
return id
}
}
return g.playerName
}
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60) // Tick Per Second auf 60 setzen
ebiten.SetVsyncEnabled(true) // VSync aktivieren
if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}