Same approach as connect4 — extract GameContent component and patch it as a single element, letting DOM morphing handle the diff.
317 lines
7.5 KiB
Go
317 lines
7.5 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
|
|
}
|
|
room.Receive(msg.Data)
|
|
if err := patchAll(); 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(), "nickname", 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
|
|
}
|
|
}
|
|
}
|