diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 3bf1893..28b79f0 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -5,6 +5,7 @@ import ( "image/color" "log" "math" + "math/rand" "time" "github.com/hajimehoshi/ebiten/v2" @@ -67,6 +68,15 @@ type renderSnapshot struct { collectedPowerups map[string]bool players map[string]game.PlayerState + // Powerup Timer HUD + myHasDoubleJump bool + myDoubleJumpRemaining float64 + myDoubleJumpUsed bool + myHasGodMode bool + myGodModeRemaining float64 + myHasMagnet bool + myMagnetRemaining float64 + // Client-Prediction (aus predictionMutex) prevPredX, prevPredY float64 predX, predY float64 @@ -109,6 +119,13 @@ func (g *Game) takeRenderSnapshot(screen *ebiten.Image) renderSnapshot { if p.Name == g.playerName { snap.isDead = !p.IsAlive || p.IsSpectator snap.myScore = p.Score + snap.myHasDoubleJump = p.HasDoubleJump + snap.myDoubleJumpRemaining = p.DoubleJumpRemainingSeconds + snap.myDoubleJumpUsed = p.DoubleJumpUsed + snap.myHasGodMode = p.HasGodMode + snap.myGodModeRemaining = p.GodModeRemainingSeconds + snap.myHasMagnet = p.HasMagnet + snap.myMagnetRemaining = p.MagnetRemainingSeconds break } } @@ -227,6 +244,12 @@ func (g *Game) UpdateGame() { g.predictionMutex.Unlock() g.SendInputWithSequence(input) + + // Trail: store predicted position every physics step + g.trail = append(g.trail, trailPoint{X: g.predictedX, Y: g.predictedY}) + if len(g.trail) > 8 { + g.trail = g.trail[1:] + } } // --- 6. KAMERA LOGIK (mit Smoothing) --- @@ -363,19 +386,46 @@ func (g *Game) DrawGame(screen *ebiten.Image) { snap := g.takeRenderSnapshot(screen) - g.drawBackground(screen, snap) - g.RenderGround(screen, g.camX/snap.viewScale) - g.drawWorldObjects(screen, snap) - g.drawPlayers(screen, snap) - g.drawStatusUI(screen, snap) - g.drawDeathZoneLine(screen, snap.canvasH) - g.RenderParticles(screen) + // Screen Shake: draw to offscreen buffer when active + target := screen + if g.shakeFrames > 0 { + w, h := screen.Size() + if g.shakeBuffer == nil { + g.shakeBuffer = ebiten.NewImage(w, h) + } else if bw, bh := g.shakeBuffer.Size(); bw != w || bh != h { + g.shakeBuffer = ebiten.NewImage(w, h) + } + g.shakeBuffer.Clear() + target = g.shakeBuffer + g.shakeFrames-- + } + + g.drawBackground(target, snap) + g.RenderGround(target, g.camX/snap.viewScale) + g.drawTeacher(target, snap) + g.drawWorldObjects(target, snap) + g.drawPlayers(target, snap) + g.drawStatusUI(target, snap) + g.drawDeathZoneLine(target, snap.canvasH) + g.RenderParticles(target) if g.showDebug { - g.drawDebugOverlay(screen) + g.drawDebugOverlay(target) } if !g.keyboardUsed { - g.drawTouchControls(screen) + g.drawTouchControls(target) + } + + // Blit shakeBuffer to screen with random offset + if target != screen { + ox := (rand.Float64()*2 - 1) * g.shakeIntensity + oy := (rand.Float64()*2 - 1) * g.shakeIntensity + if g.shakeFrames == 0 { + g.shakeIntensity = 0 + } + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(ox, oy) + screen.DrawImage(g.shakeBuffer, op) } } @@ -483,6 +533,17 @@ func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) { sprite := g.selectPlayerSprite(p.OnGround, p.VY) screenY := WorldToScreenYWithHeight(posY, snap.canvasH) + if p.Name == g.playerName && len(g.trail) > 1 { + for i, tp := range g.trail { + ratio := float32(i+1) / float32(len(g.trail)) + alpha := uint8(ratio * 80) + r := float32(8 * ratio) + tx := float32(tp.X - g.camX) + ty := float32(WorldToScreenYWithHeight(tp.Y, snap.canvasH)) + vector.DrawFilledCircle(screen, tx, ty, r, color.RGBA{200, 220, 255, alpha}, false) + } + } + g.DrawAsset(screen, sprite, posX, screenY) name := p.Name @@ -527,6 +588,7 @@ func (g *Game) drawStatusUI(screen *ebiten.Image, snap renderSnapshot) { msg := fmt.Sprintf("GO IN: %d", snap.timeLeft) text.Draw(screen, msg, basicfont.Face7x13, snap.canvasW/2-40, snap.canvasH/2, color.RGBA{255, 255, 0, 255}) case "RUNNING": + g.drawPowerupHUD(screen, snap) g.drawDangerOverlay(screen, snap) g.drawScoreBox(screen, snap) if snap.isDead { @@ -579,6 +641,54 @@ func (g *Game) drawSpectatorOverlay(screen *ebiten.Image, snap renderSnapshot) { text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", snap.myScore), basicfont.Face7x13, snap.canvasW/2-90, 55, color.RGBA{255, 255, 0, 255}) } +// drawPowerupHUD zeichnet Timer-Balken für aktive Powerups (oben links). +func (g *Game) drawPowerupHUD(screen *ebiten.Image, snap renderSnapshot) { + type bar struct { + label string + remaining float64 + maxTime float64 + col color.RGBA + active bool + used bool + } + bars := []bar{ + {"JUMP x2", snap.myDoubleJumpRemaining, 15.0, color.RGBA{100, 200, 255, 255}, snap.myHasDoubleJump, snap.myDoubleJumpUsed}, + {"GODMODE", snap.myGodModeRemaining, 10.0, color.RGBA{255, 215, 0, 255}, snap.myHasGodMode, false}, + {"MAGNET", snap.myMagnetRemaining, 8.0, color.RGBA{255, 80, 220, 255}, snap.myHasMagnet, false}, + } + x := float32(10) + y := float32(60) + barW := float32(110) + barH := float32(13) + for _, b := range bars { + if !b.active { + continue + } + ratio := float32(b.remaining / b.maxTime) + if ratio > 1 { + ratio = 1 + } + if ratio < 0 { + ratio = 0 + } + // Background + vector.DrawFilledRect(screen, x, y, barW, barH, color.RGBA{30, 30, 30, 200}, false) + // Fill — blinks red when < 30% + fillCol := b.col + if b.used { + fillCol = color.RGBA{fillCol.R / 3, fillCol.G / 3, fillCol.B / 3, fillCol.A} + } + if ratio < 0.3 && (time.Now().UnixMilli()/250)%2 == 0 { + fillCol = color.RGBA{255, 60, 60, 255} + } + vector.DrawFilledRect(screen, x, y, barW*ratio, barH, fillCol, false) + vector.StrokeRect(screen, x, y, barW, barH, 1, color.RGBA{140, 140, 140, 180}, false) + label := fmt.Sprintf("%s %.0fs", b.label, b.remaining) + text.Draw(screen, label, basicfont.Face7x13, int(x)+2, int(y)+10, color.White) + y += barH + 5 + } +} + // drawDeathZoneLine zeichnet die rote Todes-Linie am linken Bildschirmrand. func (g *Game) drawDeathZoneLine(screen *ebiten.Image, canvasH int) { vector.StrokeLine(screen, 0, 0, 0, float32(canvasH), 10, color.RGBA{255, 0, 0, 128}, false) @@ -651,6 +761,74 @@ func (g *Game) drawTouchControls(screen *ebiten.Image) { text.Draw(screen, "▼", basicfont.Face7x13, int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180}) } +// TriggerShake aktiviert den Screen-Shake-Effekt. +func (g *Game) TriggerShake(frames int, intensity float64) { + if frames > g.shakeFrames { + g.shakeFrames = frames + } + if intensity > g.shakeIntensity { + g.shakeIntensity = intensity + } +} + +// drawTeacher zeichnet den Lehrer-Charakter am linken Bildschirmrand. +func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) { + if snap.status != "RUNNING" && snap.status != "COUNTDOWN" { + return + } + + danger := snap.difficultyFactor + groundY := float32(GetFloorYFromHeight(snap.canvasH)) + + // Teacher slides in from the left as danger increases + // At danger=0: fully offscreen (-70). At danger=1: at X=5. + teacherCX := float32(-70 + danger*75) + + bodyW := float32(28) + bodyH := float32(55 + danger*15) + headR := float32(14) + + bodyX := teacherCX - bodyW/2 + bodyY := groundY - bodyH + + alpha := uint8(40 + danger*215) + + // Shadow on left edge (red vignette) + shadowW := int(20 + danger*40) + for i := 0; i < shadowW; i++ { + a := uint8(float64(70) * float64(shadowW-i) / float64(shadowW) * danger) + vector.DrawFilledRect(screen, float32(i), 0, 1, float32(snap.canvasH), color.RGBA{200, 0, 0, a}, false) + } + + // Only draw body when partially visible + if teacherCX > -40 { + // Body (dark suit) + vector.DrawFilledRect(screen, bodyX, bodyY, bodyW, bodyH, color.RGBA{100, 10, 10, alpha}, false) + // Tie + vector.DrawFilledRect(screen, teacherCX-3, bodyY+4, 6, bodyH-8, color.RGBA{180, 0, 0, alpha}, false) + // Head + vector.DrawFilledCircle(screen, teacherCX, bodyY-headR, headR, color.RGBA{210, 160, 110, alpha}, false) + // Angry eyes + eyeA := uint8(200) + vector.DrawFilledCircle(screen, teacherCX-5, bodyY-headR-1, 3, color.RGBA{255, 0, 0, eyeA}, false) + vector.DrawFilledCircle(screen, teacherCX+5, bodyY-headR-1, 3, color.RGBA{255, 0, 0, eyeA}, false) + // Legs + vector.DrawFilledRect(screen, bodyX+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false) + vector.DrawFilledRect(screen, bodyX+bodyW/2+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false) + } + + // Warning text — blinks when close + if danger > 0.55 { + if (time.Now().UnixMilli()/300)%2 == 0 { + warnX := int(teacherCX) - 20 + if warnX < 2 { + warnX = 2 + } + text.Draw(screen, "LEHRER!", basicfont.Face7x13, warnX, int(bodyY)-20, color.RGBA{255, 50, 50, 255}) + } + } +} + // --- ASSET HELPER --- // DrawAsset zeichnet ein Asset an einer Welt-Position auf den Screen. diff --git a/cmd/client/gameover_native.go b/cmd/client/gameover_native.go index b27df6a..d0dda0d 100644 --- a/cmd/client/gameover_native.go +++ b/cmd/client/gameover_native.go @@ -43,6 +43,18 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) { // Großes GAME OVER text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 60, color.RGBA{255, 0, 0, 255}) + // Highscore prüfen und aktualisieren + if myScore > g.localHighscore { + g.localHighscore = myScore + g.saveHighscore(myScore) + } + // Persönlicher Highscore anzeigen + if myScore == g.localHighscore && myScore > 0 { + text.Draw(screen, fmt.Sprintf("★ NEUER REKORD: %d ★", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-80, 85, color.RGBA{255, 215, 0, 255}) + } else { + text.Draw(screen, fmt.Sprintf("Persönlicher Highscore: %d", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-80, 85, color.Gray{Y: 180}) + } + // Linke Seite: Raum-Ergebnisse - Daten KOPIEREN mit Lock, dann außerhalb zeichnen text.Draw(screen, "=== RAUM ERGEBNISSE ===", basicfont.Face7x13, 50, 120, color.RGBA{255, 255, 0, 255}) diff --git a/cmd/client/main.go b/cmd/client/main.go index 3bc2dc3..5ca5ff9 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -40,6 +40,11 @@ var ( ColDirt = color.RGBA{101, 67, 33, 255} ) +// trailPoint speichert eine Position für den Player-Trail +type trailPoint struct { + X, Y float64 +} + // InputState speichert einen einzelnen Input für Replay type InputState struct { Sequence uint32 @@ -118,6 +123,11 @@ type Game struct { correctionOffsetX float64 correctionOffsetY float64 + // Screen Shake + shakeFrames int + shakeIntensity float64 + shakeBuffer *ebiten.Image + // Particle System particles []Particle particlesMutex sync.Mutex @@ -125,6 +135,10 @@ type Game struct { lastCollectedCoins map[string]bool // Für Coin-Partikel lastCollectedPowerups map[string]bool // Für Powerup-Partikel lastPlayerStates map[string]game.PlayerState // Für Death-Partikel + trail []trailPoint // Player Trail + + // Highscore + localHighscore int // Audio System audio *AudioSystem @@ -193,6 +207,7 @@ func NewGame() *Game { } g.loadAssets() g.loadOrCreatePlayerCode() + g.localHighscore = g.loadHighscore() // Gespeicherten Namen laden savedName := g.loadPlayerName() @@ -446,6 +461,11 @@ func (g *Game) DrawMenu(screen *ebiten.Image) { title := "ESCAPE FROM TEACHER" text.Draw(screen, title, basicfont.Face7x13, ScreenWidth/2-80, 100, ColText) + if g.localHighscore > 0 { + hsText := fmt.Sprintf("Persönlicher Highscore: %d", g.localHighscore) + text.Draw(screen, hsText, basicfont.Face7x13, ScreenWidth/2-70, 120, color.RGBA{255, 215, 0, 255}) + } + // Name-Feld fieldW := 250 nameX := ScreenWidth/2 - fieldW/2 diff --git a/cmd/client/particles.go b/cmd/client/particles.go index 678fabe..de10649 100644 --- a/cmd/client/particles.go +++ b/cmd/client/particles.go @@ -154,6 +154,7 @@ func (g *Game) SpawnDeathParticles(x, y float64) { FadeOut: true, }) } + g.TriggerShake(12, 7.0) } // UpdateParticles aktualisiert alle Partikel diff --git a/cmd/client/storage_native.go b/cmd/client/storage_native.go index 95f96b9..ec6aca7 100644 --- a/cmd/client/storage_native.go +++ b/cmd/client/storage_native.go @@ -6,6 +6,7 @@ package main import ( "crypto/rand" "encoding/hex" + "fmt" "io/ioutil" "log" "strings" @@ -61,3 +62,19 @@ func (g *Game) savePlayerName(name string) { log.Printf("💾 Spielername gespeichert: %s", name) } } + +func (g *Game) loadHighscore() int { + const hsFile = "highscore.txt" + if data, err := ioutil.ReadFile(hsFile); err == nil { + var score int + if _, err2 := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &score); err2 == nil { + return score + } + } + return 0 +} + +func (g *Game) saveHighscore(score int) { + const hsFile = "highscore.txt" + ioutil.WriteFile(hsFile, []byte(fmt.Sprintf("%d", score)), 0644) +} diff --git a/cmd/client/storage_wasm.go b/cmd/client/storage_wasm.go index 9376a02..8f7c902 100644 --- a/cmd/client/storage_wasm.go +++ b/cmd/client/storage_wasm.go @@ -6,6 +6,7 @@ package main import ( "crypto/rand" "encoding/hex" + "fmt" "log" "syscall/js" ) @@ -73,3 +74,30 @@ func (g *Game) savePlayerName(name string) { } } } + +func (g *Game) loadHighscore() int { + const storageKey = "escape_from_teacher_highscore" + if jsGlobal := js.Global(); !jsGlobal.IsUndefined() { + localStorage := jsGlobal.Get("localStorage") + if !localStorage.IsUndefined() { + stored := localStorage.Call("getItem", storageKey) + if !stored.IsNull() && stored.String() != "" { + var score int + if _, err := fmt.Sscanf(stored.String(), "%d", &score); err == nil { + return score + } + } + } + } + return 0 +} + +func (g *Game) saveHighscore(score int) { + const storageKey = "escape_from_teacher_highscore" + if jsGlobal := js.Global(); !jsGlobal.IsUndefined() { + localStorage := jsGlobal.Get("localStorage") + if !localStorage.IsUndefined() { + localStorage.Call("setItem", storageKey, fmt.Sprintf("%d", score)) + } + } +}