Previously patchAll() re-rendered the full GameContent on every chat message, which was inefficient and could cause UI glitches. Now we append just the new ChatMessage to the chat history element.
322 lines
7.6 KiB
Go
322 lines
7.6 KiB
Go
package snakegame
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/starfederation/datastar-go/datastar"
|
|
|
|
"github.com/ryanhamamura/games/chat"
|
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
|
"github.com/ryanhamamura/games/features/snakegame/pages"
|
|
"github.com/ryanhamamura/games/sessions"
|
|
"github.com/ryanhamamura/games/snake"
|
|
)
|
|
|
|
func snakeChatColor(slot int) string {
|
|
if slot >= 0 && slot < len(snake.SnakeColors) {
|
|
return snake.SnakeColors[slot]
|
|
}
|
|
return "#666"
|
|
}
|
|
|
|
func snakeChatConfig(gameID string) chatcomponents.Config {
|
|
return chatcomponents.Config{
|
|
CSSPrefix: "snake",
|
|
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
|
|
Color: snakeChatColor,
|
|
StopKeyPropagation: true,
|
|
}
|
|
}
|
|
|
|
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
nickname := sessions.GetNickname(sm, r)
|
|
userID := sessions.GetUserID(sm, r)
|
|
|
|
// Auto-join if nickname exists and not already in game
|
|
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
|
p := &snake.Player{
|
|
ID: playerID,
|
|
Nickname: nickname,
|
|
}
|
|
if userID != "" {
|
|
p.UserID = &userID
|
|
}
|
|
si.Join(p)
|
|
}
|
|
|
|
mySlot := si.GetPlayerSlot(playerID)
|
|
|
|
if mySlot < 0 {
|
|
isGuest := r.URL.Query().Get("guest") == "1"
|
|
if userID == "" && !isGuest {
|
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
sg := si.GetGame()
|
|
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
mySlot := si.GetPlayerSlot(playerID)
|
|
|
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
|
))
|
|
|
|
chatCfg := snakeChatConfig(gameID)
|
|
|
|
// Chat room (multiplayer only)
|
|
var room *chat.Room
|
|
sg := si.GetGame()
|
|
if sg.Mode == snake.ModeMultiplayer {
|
|
room = chat.NewRoom(nc, snake.ChatSubject(gameID))
|
|
}
|
|
|
|
chatMessages := func() []chat.Message {
|
|
if room == nil {
|
|
return nil
|
|
}
|
|
return room.Messages()
|
|
}
|
|
|
|
patchAll := func() error {
|
|
si, ok = snakeStore.Get(gameID)
|
|
if !ok {
|
|
return fmt.Errorf("game not found")
|
|
}
|
|
mySlot = si.GetPlayerSlot(playerID)
|
|
sg = si.GetGame()
|
|
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
|
|
}
|
|
|
|
// Send initial render
|
|
if err := patchAll(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Subscribe to game updates via NATS
|
|
gameCh := make(chan *nats.Msg, 64)
|
|
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
|
|
|
// Chat subscription (multiplayer only)
|
|
var chatCh chan *nats.Msg
|
|
var chatSub *nats.Subscription
|
|
|
|
if room != nil {
|
|
chatCh, chatSub, err = room.Subscribe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer chatSub.Unsubscribe() //nolint:errcheck
|
|
}
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case <-gameCh:
|
|
// Drain backed-up game updates
|
|
for {
|
|
select {
|
|
case <-gameCh:
|
|
default:
|
|
goto drained
|
|
}
|
|
}
|
|
drained:
|
|
if err := patchAll(); err != nil {
|
|
return
|
|
}
|
|
|
|
case msg := <-chatCh:
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
chatMsg, _ := room.Receive(msg.Data)
|
|
err := sse.PatchElementTempl(
|
|
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
|
datastar.WithSelectorID("snake-chat-history"),
|
|
datastar.WithModeAppend(),
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
slot := si.GetPlayerSlot(playerID)
|
|
if slot < 0 {
|
|
http.Error(w, "not in game", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
dStr := r.URL.Query().Get("d")
|
|
d, err := strconv.Atoi(dStr)
|
|
if err != nil || d < 0 || d > 3 {
|
|
http.Error(w, "invalid direction", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
si.SetDirection(slot, snake.Direction(d))
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|
|
|
|
type chatSignals struct {
|
|
ChatMsg string `json:"chatMsg"`
|
|
}
|
|
|
|
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var signals chatSignals
|
|
if err := datastar.ReadSignals(r, &signals); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if signals.ChatMsg == "" {
|
|
return
|
|
}
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
slot := si.GetPlayerSlot(playerID)
|
|
if slot < 0 {
|
|
http.Error(w, "not in game", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
sg := si.GetGame()
|
|
msg := chat.Message{
|
|
Nickname: sg.Players[slot].Nickname,
|
|
Slot: slot,
|
|
Message: signals.ChatMsg,
|
|
}
|
|
|
|
room := chat.NewRoom(nc, snake.ChatSubject(gameID))
|
|
room.Send(msg)
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
type nicknameSignals struct {
|
|
Nickname string `json:"nickname"`
|
|
}
|
|
|
|
func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var signals nicknameSignals
|
|
if err := datastar.ReadSignals(r, &signals); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if signals.Nickname == "" {
|
|
return
|
|
}
|
|
|
|
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
userID := sessions.GetUserID(sm, r)
|
|
|
|
if si.GetPlayerSlot(playerID) < 0 {
|
|
p := &snake.Player{
|
|
ID: playerID,
|
|
Nickname: signals.Nickname,
|
|
}
|
|
if userID != "" {
|
|
p.UserID = &userID
|
|
}
|
|
si.Join(p)
|
|
}
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.Redirect("/snake/" + gameID) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
newSI := si.CreateRematch()
|
|
sse := datastar.NewSSE(w, r)
|
|
if newSI != nil {
|
|
sse.Redirect("/snake/" + newSI.ID()) //nolint:errcheck
|
|
}
|
|
}
|
|
}
|