Create chat/ package with Message type, Room (NATS pub/sub + buffer), DB persistence helpers, and a unified templ component parameterized by Config (CSS prefix, post URL, color function, key propagation). Both c4game and snakegame now use chat.Room for message management and chatcomponents.Chat for rendering, eliminating the duplicated ChatMessage types, chat templ components, chatAutoScroll scripts, color functions, and inline buffer management.
318 lines
8.0 KiB
Go
318 lines
8.0 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/c4/chat"
|
|
chatcomponents "github.com/ryanhamamura/c4/chat/components"
|
|
"github.com/ryanhamamura/c4/features/snakegame/components"
|
|
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
|
"github.com/ryanhamamura/c4/sessions"
|
|
"github.com/ryanhamamura/c4/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)
|
|
|
|
// Send initial render
|
|
sg := si.GetGame()
|
|
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
|
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
|
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
|
if sg.Mode == snake.ModeMultiplayer {
|
|
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
|
|
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
|
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
// Subscribe to game updates via NATS
|
|
gameCh := make(chan *nats.Msg, 64)
|
|
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
|
|
|
// Chat subscription (multiplayer only)
|
|
var chatCh chan *nats.Msg
|
|
var chatSub *nats.Subscription
|
|
var room *chat.Room
|
|
|
|
if sg.Mode == snake.ModeMultiplayer {
|
|
room = chat.NewRoom(nc, "snake.chat."+gameID, 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:
|
|
si, ok = snakeStore.Get(gameID)
|
|
if !ok {
|
|
return
|
|
}
|
|
mySlot = si.GetPlayerSlot(playerID)
|
|
sg = si.GetGame()
|
|
if err := sse.PatchElementTempl(components.Board(sg)); err != nil {
|
|
return
|
|
}
|
|
if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil {
|
|
return
|
|
}
|
|
if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil {
|
|
return
|
|
}
|
|
|
|
case msg := <-chatCh:
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
_, snapshot := room.Receive(msg.Data)
|
|
if snapshot == nil {
|
|
continue
|
|
}
|
|
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); 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.chat."+gameID, nil)
|
|
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
|
|
}
|
|
}
|
|
}
|