diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 8f9b2bf..ef0762a 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -6,6 +6,7 @@ import ( "log" "math" "math/rand" + "strings" "time" "github.com/hajimehoshi/ebiten/v2" @@ -265,6 +266,20 @@ func (g *Game) UpdateGame() { g.updateQuotes() } + // --- EMOTES --- + if inpututil.IsKeyJustPressed(ebiten.Key1) { + g.SendCommand("EMOTE_1") + } + if inpututil.IsKeyJustPressed(ebiten.Key2) { + g.SendCommand("EMOTE_2") + } + if inpututil.IsKeyJustPressed(ebiten.Key3) { + g.SendCommand("EMOTE_3") + } + if inpututil.IsKeyJustPressed(ebiten.Key4) { + g.SendCommand("EMOTE_4") + } + // --- 6. KAMERA LOGIK (mit Smoothing) --- g.stateMutex.Lock() targetCam := g.gameState.ScrollX @@ -330,6 +345,15 @@ func (g *Game) handleTouchInput() { x, y := ebiten.TouchPosition(id) fx, fy := float64(x), float64(y) + // ── EMOTES ─────────────────────────────────────────────────────────── + if fx >= float64(g.lastCanvasWidth)-80.0 && fy >= 40.0 && fy <= 250.0 && isJustPressed(id) { + emoteIdx := int((fy - 50.0) / 50.0) + if emoteIdx >= 0 && emoteIdx <= 3 { + g.SendCommand(fmt.Sprintf("EMOTE_%d", emoteIdx+1)) + } + continue + } + if fx >= halfW { // ── RECHTE SEITE: Jump und Down ────────────────────────────────────── g.btnJumpPressed = true @@ -563,7 +587,25 @@ func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) { if name == "" { name = id } - text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText) + + // In Presentation Mode normal players don't show names, only Host/PRESENTATION does (which is hidden anyway) + if snap.status != "PRESENTATION" || name == g.playerName { + text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText) + } + + // Draw Emote if active + if p.State != "" && strings.HasPrefix(p.State, "EMOTE_") { + emoteStr := p.State[6:] // e.g. EMOTE_1 -> "1" + emoteMap := map[string]string{ + "1": "❤️", + "2": "😂", + "3": "😡", + "4": "👍", + } + if emoji, ok := emoteMap[emoteStr]; ok { + text.Draw(screen, emoji, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale+15), int(screenY-40), color.White) + } + } if g.showDebug { g.drawPlayerHitbox(screen, posX, screenY, snap.viewScale) @@ -779,6 +821,20 @@ func (g *Game) drawTouchControls(screen *ebiten.Image) { vector.DrawFilledCircle(screen, float32(downX), float32(downY), float32(downR), color.RGBA{50, 120, 220, 55}, false) vector.StrokeCircle(screen, float32(downX), float32(downY), float32(downR), 2, color.RGBA{80, 160, 255, 120}, false) text.Draw(screen, "▼", basicfont.Face7x13, int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180}) + + // ── D) Emote Buttons (oben rechts) ───────────────────────────────────────── + emoteY := 50.0 + emoteXBase := float64(tcW) - 60.0 + emoteSize := 40.0 + emotes := []string{"❤️", "😂", "😡", "👍"} + + for i, em := range emotes { + x := emoteXBase + y := emoteY + float64(i)*50.0 + vector.DrawFilledRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), color.RGBA{0, 0, 0, 100}, false) + vector.StrokeRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), 2, color.RGBA{255, 255, 255, 100}, false) + text.Draw(screen, em, basicfont.Face7x13, int(x)+10, int(y)+25, color.White) + } } // TriggerShake aktiviert den Screen-Shake-Effekt. diff --git a/cmd/client/go.mod b/cmd/client/go.mod index 7533100..36b48da 100644 --- a/cmd/client/go.mod +++ b/cmd/client/go.mod @@ -15,6 +15,7 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/cmd/client/go.sum b/cmd/client/go.sum index b4394f9..5eed40e 100644 --- a/cmd/client/go.sum +++ b/cmd/client/go.sum @@ -16,6 +16,8 @@ github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= diff --git a/cmd/client/main.go b/cmd/client/main.go index 1e82064..32af19a 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -184,6 +184,7 @@ type Game struct { presQuoteTime time.Time presAssets []presAssetInstance lastPresUpdate time.Time + presQRCode *ebiten.Image // Kamera camX float64 @@ -281,10 +282,24 @@ func (g *Game) Update() error { if inpututil.IsKeyJustPressed(ebiten.KeyF1) { if g.appState == StatePresentation { g.appState = StateMenu + g.disconnectFromServer() } else { g.appState = StatePresentation g.presAssets = nil // Reset assets g.presQuoteTime = time.Now() // Force immediate first quote + + // Setup Server Connection for Presentation Mode + g.gameMode = "coop" // Use coop logic on server + g.isHost = true + g.roomID = "PRES" + generateRoomCode() + g.playerName = "PRESENTATION" + + // Start connection process in background + go g.connectAndStart() + + // Generate QR Code URL + joinURL := "https://escape-from-school.de/?room=" + g.roomID + g.presQRCode = generateQRCode(joinURL) } } @@ -316,8 +331,8 @@ func (g *Game) Update() error { } } - // COUNTDOWN/RUNNING-Übergang: AppState auf StateGame setzen + JS benachrichtigen - if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING") && g.appState != StateGame { + // COUNTDOWN/RUNNING/PRESENTATION-Übergang: AppState auf StateGame setzen + JS benachrichtigen + if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING" || currentStatus == "PRESENTATION") && g.appState != StateGame && g.appState != StatePresentation { log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus) g.appState = StateGame g.notifyGameStarted() diff --git a/cmd/client/presentation.go b/cmd/client/presentation.go new file mode 100644 index 0000000..289e9d8 --- /dev/null +++ b/cmd/client/presentation.go @@ -0,0 +1,217 @@ +package main + +import ( + "bytes" + "fmt" + "image" + _ "image/png" + "image/color" + "math" + "math/rand" + "strings" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/vector" + "github.com/skip2/go-qrcode" + "golang.org/x/image/font/basicfont" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +type presAssetInstance struct { + AssetID string + X, Y float64 + VX float64 + Scale float64 +} + +// generateQRCode erstellt ein ebiten.Image aus einem QR-Code +func generateQRCode(url string) *ebiten.Image { + pngData, err := qrcode.Encode(url, qrcode.Medium, 256) + if err != nil { + fmt.Println("Error generating QR code:", err) + return nil + } + + img, _, err := image.Decode(bytes.NewReader(pngData)) + if err != nil { + fmt.Println("Error decoding QR code:", err) + return nil + } + return ebiten.NewImageFromImage(img) +} + +// updatePresentation verarbeitet die Logik für den Präsentationsmodus. +func (g *Game) updatePresentation() { + now := time.Now() + + // Auto-Start Presentation Room when connected + g.stateMutex.Lock() + status := g.gameState.Status + g.stateMutex.Unlock() + + if g.connected && status == "LOBBY" && g.isHost { + g.SendCommand("START_PRESENTATION") + } + + // 1. Zitat-Wechsel alle 6 Sekunden + if now.After(g.presQuoteTime) { + g.presQuote = game.GetRandomQuote() + g.presQuoteTime = now.Add(6 * time.Second) + } + + // 2. Assets spawnen (wenn zu wenige da sind) + if len(g.presAssets) < 8 && rand.Float64() < 0.05 { + // Wähle zufälliges Asset (Schüler, Lehrer, Items) + assetList := []string{"player", "coin", "eraser", "pc-trash", "godmode", "jumpboost", "magnet"} + id := assetList[rand.Intn(len(assetList))] + + g.presAssets = append(g.presAssets, presAssetInstance{ + AssetID: id, + X: float64(ScreenWidth + 100), + Y: float64(ScreenHeight - 150 - rand.Intn(100)), + VX: -(2.0 + rand.Float64()*4.0), + Scale: 1.0 + rand.Float64()*0.5, + }) + } + + // 3. Assets bewegen + newAssets := g.presAssets[:0] + for _, a := range g.presAssets { + a.X += a.VX + if a.X > -200 { // Noch im Bildbereich (mit Puffer) + newAssets = append(newAssets, a) + } + } + g.presAssets = newAssets +} + +// drawPresentation zeichnet den Präsentationsmodus. +func (g *Game) drawPresentation(screen *ebiten.Image) { + // Hintergrund: Retro Dunkelblau + screen.Fill(color.RGBA{10, 15, 30, 255}) + + // Animierte Scanlines / Raster-Effekt (Retro Style) + for i := 0; i < ScreenHeight; i += 4 { + vector.DrawFilledRect(screen, 0, float32(i), float32(ScreenWidth), 1, color.RGBA{0, 0, 0, 40}, false) + } + + // Überschrift + text.Draw(screen, "PRESENTATION MODE", basicfont.Face7x13, ScreenWidth/2-80, 50, color.RGBA{255, 255, 0, 255}) + vector.StrokeLine(screen, ScreenWidth/2-90, 60, ScreenWidth/2+90, 60, 2, color.RGBA{255, 255, 0, 255}, false) + + // Zitat groß in der Mitte + if g.presQuote.Text != "" { + quoteMsg := fmt.Sprintf("\"%s\"", g.presQuote.Text) + authorMsg := fmt.Sprintf("- %s", g.presQuote.Author) + + // Einfaches Word-Wrap (sehr rudimentär) + drawWrappedText(screen, quoteMsg, ScreenWidth/2, ScreenHeight/2-20, 600, color.White) + text.Draw(screen, authorMsg, basicfont.Face7x13, ScreenWidth/2+100, ScreenHeight/2+50, color.RGBA{200, 200, 200, 255}) + } + + // Assets laufen unten durch + for _, a := range g.presAssets { + def, ok := g.world.Manifest.Assets[a.AssetID] + if !ok { continue } + + img, ok := g.assetsImages[a.AssetID] + if !ok { continue } + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(a.Scale, a.Scale) + op.GeoM.Translate(a.X, a.Y) + + // Leichtes Pulsieren/Animation + bob := math.Sin(float64(time.Now().UnixMilli())/200.0) * 5.0 + op.GeoM.Translate(0, bob) + + screen.DrawImage(img, op) + + // Name des Assets drunter schreiben + text.Draw(screen, def.ID, basicfont.Face7x13, int(a.X), int(a.Y+80), color.RGBA{100, 200, 255, 150}) + } + + // Draw connected players (no names) + g.stateMutex.Lock() + for _, p := range g.gameState.Players { + if !p.IsAlive || p.Name == "PRESENTATION" { + continue // Skip Host and dead players + } + + // Map player X/Y to screen + playerX := p.X + playerY := p.Y + + // Keep players somewhat in bounds if they walk too far + if playerX > ScreenWidth { + playerX = math.Mod(playerX, ScreenWidth) + } else if playerX < 0 { + playerX = ScreenWidth - math.Mod(-playerX, ScreenWidth) + } + + // Draw simple player sprite + img, ok := g.assetsImages["player"] + if ok { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(playerX, playerY) + screen.DrawImage(img, op) + } else { + // Fallback rect + vector.DrawFilledRect(screen, float32(playerX), float32(playerY), 40, 60, color.RGBA{0, 255, 0, 255}, false) + } + + // Draw Emote if active + if p.State != "" && strings.HasPrefix(p.State, "EMOTE_") { + emoteStr := p.State[6:] // e.g. EMOTE_1 -> "1" + emoteMap := map[string]string{ + "1": "❤️", + "2": "😂", + "3": "😡", + "4": "👍", + } + if emoji, ok := emoteMap[emoteStr]; ok { + text.Draw(screen, emoji, basicfont.Face7x13, int(playerX+10), int(playerY-10), color.White) + } + } + } + g.stateMutex.Unlock() + + // Draw QR Code + if g.presQRCode != nil { + qrSize := 150.0 + qrW, _ := g.presQRCode.Size() + scale := float64(qrSize) / float64(qrW) + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(20, 20) + screen.DrawImage(g.presQRCode, op) + + // Instruction + text.Draw(screen, "SCANNEN ZUM MITMACHEN!", basicfont.Face7x13, 20, 190, color.RGBA{255, 255, 0, 255}) + } + + // Hotkey Info + text.Draw(screen, "DRÜCKE [F1] ZUM BEENDEN", basicfont.Face7x13, ScreenWidth-250, ScreenHeight-30, color.RGBA{255, 255, 255, 100}) +} + +// drawWrappedText zeichnet Text mit automatischem Zeilenumbruch. +func drawWrappedText(screen *ebiten.Image, str string, x, y, maxWidth int, col color.Color) { + words := strings.Split(str, " ") + line := "" + currY := y + + for _, w := range words { + testLine := line + w + " " + if len(testLine)*7 > maxWidth { // Grobe Schätzung Breite + text.Draw(screen, line, basicfont.Face7x13, x-len(line)*7/2, currY, col) + line = w + " " + currY += 20 + } else { + line = testLine + } + } + text.Draw(screen, line, basicfont.Face7x13, x-len(line)*7/2, currY, col) +} diff --git a/cmd/client/web/game.js b/cmd/client/web/game.js index 84326e4..1209e60 100644 --- a/cmd/client/web/game.js +++ b/cmd/client/web/game.js @@ -684,6 +684,21 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('playerName').value = savedName; } + // Auto-Join if URL parameter ?room=XYZ is present + const urlParams = new URLSearchParams(window.location.search); + const roomParam = urlParams.get('room'); + if (roomParam) { + document.getElementById('joinRoomCode').value = roomParam; + + // Wait for WASM to be ready, then auto-join + const checkWASM = setInterval(() => { + if (wasmReady) { + clearInterval(checkWASM); + joinRoom(); + } + }, 100); + } + // Load local highscore const highscore = localStorage.getItem('escape_local_highscore') || 0; const hsElement = document.getElementById('localHighscore'); diff --git a/pkg/game/quotes.go b/pkg/game/quotes.go new file mode 100644 index 0000000..3d4258e --- /dev/null +++ b/pkg/game/quotes.go @@ -0,0 +1,49 @@ +package game + +import "math/rand" + +type Quote struct { + Text string + Author string + Ctx string +} + +var Quotes = []Quote{ + {Text: "Mobbing ist besser als gar keine sozialen Kontakte.", Author: "Ein Lehrer"}, + {Text: "Was heißt Strafe auf Englisch? „Richard“?", Author: "Ein Lehrer"}, + {Text: "Heute ist alles richtig eingetragen.", Author: "Eine Lehrerin"}, + {Text: "Verstehen Sie überhaupt die Prüfungsfragen? Neh, ach …", Author: "Ein Lehrer"}, + {Text: "Ist das eine rechtsextreme Handlung?!", Author: "Schüler"}, + {Text: "Ich bin mit dem Staat verheiratet. … Ich hab das nur wegen der Pension gemacht.", Author: "Ein Lehrer"}, + {Text: "Ich hab 'nen Freund! – Das ist egal.", Author: "Lehrerin & Schüler"}, + {Text: "Neues Lieblingswort: „Hanebüchen“", Author: "Ein Lehrer"}, + {Text: "Ich mag Menschen quälen.", Author: "Ein Lehrer"}, + {Text: "Morgen sind Schüler unserer polnischen Partnerschule da. Die wollen von dem Besten lernen – also von mir.", Author: "Ein Lehrer"}, + {Text: "Ich bräuchte jetzt wirklich einen Kaffee oder ein Bier.", Author: "Eine Lehrerin"}, + {Text: "Scheiße, darauf kann ich nicht rumschreiben!", Author: "Ein Lehrer"}, + {Text: "Warum muss ich jetzt wieder Scheiße erklären, die ich net verzapft hab. Lasst mich doch in Ruhe.", Author: "Ein Lehrer"}, + {Text: "Dann können Rollstuhlfahrer gleich mit in den Krieg ziehen.", Author: "Ein Lehrer"}, + {Text: "Mobbing ist 3 Monate durchgängig. Ich kann sie also nicht gar nicht mobben, weil sie immer weg sind.", Author: "Ein Lehrer"}, + {Text: "Ich spiele kein Schach mehr, seit ich gegen ein Kind verloren hab.", Author: "Ein Lehrer"}, + {Text: "Spielen Sie „God of War“? So sehen sie auch aus.", Author: "Ein Lehrer"}, + {Text: "Die Antwort soll „ja“ sein. Mit genug Reden kann man auch das Gegenteil argumentieren.", Author: "Ein Lehrer"}, + {Text: "Es geht darum, Sie drei Jahre hinzuhalten – und dann sind Sie eh weg.", Author: "Ein Lehrer"}, + {Text: "Es gibt hier gar kein Problem. ... Es gibt verdammt nochmal keine Probleme!", Author: "Ein Lehrer"}, + {Text: "Ich denke immer, ich bin doof. Aber das ist so.", Author: "Ein Lehrer"}, + {Text: "Warum wollen Sie die Schule versichern? Die können Sie sowieso nicht verklagen.", Author: "Ein Lehrer"}, + {Text: "Haltet euch sklavisch an die Notation!", Author: "Ein Lehrer"}, + {Text: "Nur die Paranoiden werden überleben.", Author: "Ein Lehrer"}, + {Text: "Ich bin gerade im Größenwahn und es wird immer verrückter.", Author: "Ein Lehrer"}, + {Text: "Programmieren kann so ekelhaft sein, wenn man sich wirklich damit beschäftigt.", Author: "Ein Lehrer"}, + {Text: "Vorsicht, sauer. Hab ich meinen Kindern geklaut.", Author: "Ein Lehrer"}, + {Text: "Es gibt noch solche von der Resterampe wie mich.", Author: "Ein Lehrer"}, + {Text: "Jetzt muss ich mir schon die Musterlösung schönsaufen.", Author: "Ein Lehrer"}, + {Text: "Ich muss die Prüfung nicht schreiben!", Author: "Ein Lehrer"}, + {Text: "Werd ich echt alt?", Author: "Ein Lehrer"}, + {Text: "Die Geißel Gottes. Ich freue mich, Sie zu sehen…!", Author: "Ein Lehrer"}, + {Text: "Kind kriegen ist glaub ich schon geiler als auf'm Mount Everest zu steigen.", Author: "Ein Lehrer"}, +} + +func GetRandomQuote() Quote { + return Quotes[rand.Intn(len(Quotes))] +} diff --git a/pkg/server/room.go b/pkg/server/room.go index 1528a01..14d482d 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -342,6 +342,12 @@ func (r *Room) HandleInput(input game.ClientInput) { if input.PlayerID == r.HostID && r.Status == "LOBBY" { r.StartCountdown() } + case "START_PRESENTATION": + if input.PlayerID == r.HostID && r.Status == "LOBBY" { + r.Status = "PRESENTATION" + r.GameStartTime = time.Now() + r.CurrentSpeed = 0 + } case "SET_TEAM_NAME": // Nur Host darf Team-Name setzen und nur in der Lobby if input.PlayerID == r.HostID && r.Status == "LOBBY" { @@ -349,6 +355,19 @@ func (r *Room) HandleInput(input game.ClientInput) { log.Printf("🏷️ Team-Name gesetzt: '%s' (von Host %s)", r.TeamName, p.Name) } } + + // Emote Handling (z.B. EMOTE_1, EMOTE_2) + if len(input.Type) > 6 && input.Type[:6] == "EMOTE_" { + p.State = input.Type + + // Emote nach 2 Sekunden zurücksetzen + go func(player *ServerPlayer, emote string) { + time.Sleep(2 * time.Second) + if player.State == emote { + player.State = "" + } + }(p, input.Type) + } } func (r *Room) StartCountdown() { @@ -388,6 +407,11 @@ func (r *Room) Update() { r.GlobalScrollX += r.CurrentSpeed // Bewegende Plattformen updaten r.UpdateMovingPlatforms() + } else if r.Status == "PRESENTATION" { + // Keine Kamera-Bewegung, keine Schwierigkeitssteigerung, aber Physik läuft weiter + r.CurrentSpeed = 0 + // Bewegende Plattformen können sich auch hier bewegen, wenn gewünscht + r.UpdateMovingPlatforms() } maxX := r.GlobalScrollX