refactor: extract standalone chat package from game-specific handlers
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.
This commit is contained in:
92
chat/chat.go
Normal file
92
chat/chat.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Package chat provides a reusable chat room backed by NATS pub/sub
|
||||||
|
// with optional database persistence.
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is the wire format for chat messages over NATS.
|
||||||
|
type Message struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Slot int `json:"slot"` // player slot/color index
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time int64 `json:"time"` // unix millis, zero for ephemeral messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMessages = 50
|
||||||
|
|
||||||
|
// Room manages an in-memory message buffer and NATS pub/sub for a single
|
||||||
|
// chat room (typically one per game).
|
||||||
|
type Room struct {
|
||||||
|
subject string
|
||||||
|
nc *nats.Conn
|
||||||
|
messages []Message
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoom creates a chat room that publishes and subscribes on the given
|
||||||
|
// NATS subject (e.g. "chat.abc123").
|
||||||
|
func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
|
||||||
|
return &Room{
|
||||||
|
subject: subject,
|
||||||
|
nc: nc,
|
||||||
|
messages: initial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send publishes a message to the room's NATS subject.
|
||||||
|
func (r *Room) Send(msg Message) {
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.nc.Publish(r.subject, data); err != nil {
|
||||||
|
log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive processes an incoming NATS message, appending it to the buffer.
|
||||||
|
// Returns the new message and a snapshot of all messages.
|
||||||
|
func (r *Room) Receive(data []byte) (Message, []Message) {
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.messages = append(r.messages, msg)
|
||||||
|
if len(r.messages) > maxMessages {
|
||||||
|
r.messages = r.messages[len(r.messages)-maxMessages:]
|
||||||
|
}
|
||||||
|
snapshot := make([]Message, len(r.messages))
|
||||||
|
copy(snapshot, r.messages)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
return msg, snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages returns a snapshot of the current message buffer.
|
||||||
|
func (r *Room) Messages() []Message {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
snapshot := make([]Message, len(r.messages))
|
||||||
|
copy(snapshot, r.messages)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe creates a NATS channel subscription for the room's subject.
|
||||||
|
// Caller is responsible for unsubscribing.
|
||||||
|
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
|
||||||
|
ch := make(chan *nats.Msg, 64)
|
||||||
|
sub, err := r.nc.ChanSubscribe(r.subject, ch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return ch, sub, nil
|
||||||
|
}
|
||||||
74
chat/components/chat.templ
Normal file
74
chat/components/chat.templ
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/c4/chat"
|
||||||
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColorFunc resolves a player slot to a CSS color string.
|
||||||
|
type ColorFunc func(slot int) string
|
||||||
|
|
||||||
|
// Config holds the game-specific settings for rendering a chat component.
|
||||||
|
type Config struct {
|
||||||
|
// CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
|
||||||
|
CSSPrefix string
|
||||||
|
// PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
|
||||||
|
PostURL string
|
||||||
|
// Color resolves a player slot to a CSS color string.
|
||||||
|
Color ColorFunc
|
||||||
|
// StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
|
||||||
|
// key events from propagating (needed for snake to avoid steering while typing).
|
||||||
|
StopKeyPropagation bool
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Chat(messages []chat.Message, cfg Config) {
|
||||||
|
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-history" }>
|
||||||
|
for _, m := range messages {
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-msg" }>
|
||||||
|
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
|
||||||
|
{ m.Nickname + ": " }
|
||||||
|
</span>
|
||||||
|
<span>{ m.Message }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
|
||||||
|
if cfg.StopKeyPropagation {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chat..."
|
||||||
|
autocomplete="off"
|
||||||
|
data-bind="chatMsg"
|
||||||
|
data-on:keydown.stop=""
|
||||||
|
data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) }
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chat..."
|
||||||
|
autocomplete="off"
|
||||||
|
data-bind="chatMsg"
|
||||||
|
data-on:keydown.enter={ datastar.PostSSE(cfg.PostURL) }
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-on:click={ datastar.PostSSE(cfg.PostURL) }
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@chatAutoScroll(cfg.CSSPrefix)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script chatAutoScroll(cssPrefix string) {
|
||||||
|
var el = document.querySelector('.' + cssPrefix + '-chat-history');
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||||
|
.observe(el, {childList:true, subtree:true});
|
||||||
|
}
|
||||||
45
chat/persist.go
Normal file
45
chat/persist.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/c4/db/repository"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveMessage persists a chat message to the database.
|
||||||
|
func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
|
||||||
|
err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
|
||||||
|
GameID: roomID,
|
||||||
|
Nickname: msg.Nickname,
|
||||||
|
Color: int64(msg.Slot),
|
||||||
|
Message: msg.Message,
|
||||||
|
CreatedAt: msg.Time,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMessages loads persisted chat messages for a room, returning them
|
||||||
|
// in chronological order (oldest first).
|
||||||
|
func LoadMessages(queries *repository.Queries, roomID string) []Message {
|
||||||
|
rows, err := queries.GetChatMessages(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msgs := make([]Message, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
msgs[i] = Message{
|
||||||
|
Nickname: r.Nickname,
|
||||||
|
Slot: int(r.Color),
|
||||||
|
Message: r.Message,
|
||||||
|
Time: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DB returns newest-first; reverse for chronological display
|
||||||
|
slices.Reverse(msgs)
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Color int `json:"color"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatColors = map[int]string{
|
|
||||||
1: "#4a2a3a",
|
|
||||||
2: "#2a4545",
|
|
||||||
}
|
|
||||||
|
|
||||||
templ Chat(messages []ChatMessage, gameID string) {
|
|
||||||
<div id="c4-chat" class="c4-chat">
|
|
||||||
<div class="c4-chat-history">
|
|
||||||
for _, m := range messages {
|
|
||||||
<div class="c4-chat-msg">
|
|
||||||
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Color)) }>
|
|
||||||
{ m.Nickname }:
|
|
||||||
</span>
|
|
||||||
<span>{ m.Message }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@chatAutoScroll()
|
|
||||||
</div>
|
|
||||||
<div class="c4-chat-input" data-morph-ignore>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Chat..."
|
|
||||||
autocomplete="off"
|
|
||||||
data-bind="chatMsg"
|
|
||||||
data-on:keydown.enter={ datastar.PostSSE("/games/%s/chat", gameID) }
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-on:click={ datastar.PostSSE("/games/%s/chat", gameID) }
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ chatAutoScroll() {
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var el = document.querySelector('.c4-chat-history');
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
|
||||||
.observe(el, {childList:true, subtree:true});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatColor(color int) string {
|
|
||||||
if c, ok := chatColors[color]; ok {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
package c4game
|
package c4game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"fmt"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
@@ -14,6 +11,8 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/c4/chat"
|
||||||
|
chatcomponents "github.com/ryanhamamura/c4/chat/components"
|
||||||
"github.com/ryanhamamura/c4/db/repository"
|
"github.com/ryanhamamura/c4/db/repository"
|
||||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
"github.com/ryanhamamura/c4/features/c4game/components"
|
||||||
"github.com/ryanhamamura/c4/features/c4game/pages"
|
"github.com/ryanhamamura/c4/features/c4game/pages"
|
||||||
@@ -21,6 +20,27 @@ import (
|
|||||||
"github.com/ryanhamamura/c4/sessions"
|
"github.com/ryanhamamura/c4/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
|
||||||
|
var c4ChatColors = map[int]string{
|
||||||
|
0: "#4a2a3a", // color 1 stored as slot 0
|
||||||
|
1: "#2a4545", // color 2 stored as slot 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func c4ChatColor(slot int) string {
|
||||||
|
if c, ok := c4ChatColors[slot]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return "#666"
|
||||||
|
}
|
||||||
|
|
||||||
|
func c4ChatConfig(gameID string) chatcomponents.Config {
|
||||||
|
return chatcomponents.Config{
|
||||||
|
CSSPrefix: "c4",
|
||||||
|
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
|
||||||
|
Color: c4ChatColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
@@ -53,25 +73,21 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
|
|||||||
// Player not in game
|
// Player not in game
|
||||||
isGuest := r.URL.Query().Get("guest") == "1"
|
isGuest := r.URL.Query().Get("guest") == "1"
|
||||||
if userID == "" && !isGuest {
|
if userID == "" && !isGuest {
|
||||||
// Show join prompt (login vs guest)
|
|
||||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Show nickname prompt
|
|
||||||
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player is in the game — render full game page
|
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
chatMsgs := loadChatMessages(queries, gameID)
|
msgs := chat.LoadMessages(queries, gameID)
|
||||||
msgs := chatToComponents(chatMsgs)
|
|
||||||
|
|
||||||
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
|
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,13 +110,11 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
|
|||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Load initial chat messages
|
chatCfg := c4ChatConfig(gameID)
|
||||||
chatMsgs := loadChatMessages(queries, gameID)
|
room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
|
||||||
var chatMu sync.Mutex
|
|
||||||
chatMessages := chatToComponents(chatMsgs)
|
|
||||||
|
|
||||||
// Send initial render of all components
|
// Send initial render
|
||||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
sendGameComponents(sse, gi, myColor, room, chatCfg)
|
||||||
|
|
||||||
// Subscribe to game state updates
|
// Subscribe to game state updates
|
||||||
gameCh := make(chan *nats.Msg, 64)
|
gameCh := make(chan *nats.Msg, 64)
|
||||||
@@ -111,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
|
|||||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||||
|
|
||||||
// Subscribe to chat messages
|
// Subscribe to chat messages
|
||||||
chatCh := make(chan *nats.Msg, 64)
|
chatCh, chatSub, err := room.Subscribe()
|
||||||
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-gameCh:
|
case <-gameCh:
|
||||||
// Re-read player color in case we just joined
|
|
||||||
myColor = gi.GetPlayerColor(playerID)
|
myColor = gi.GetPlayerColor(playerID)
|
||||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
sendGameComponents(sse, gi, myColor, room, chatCfg)
|
||||||
case msg := <-chatCh:
|
case msg := <-chatCh:
|
||||||
var uiMsg game.ChatMessage
|
_, snapshot := room.Receive(msg.Data)
|
||||||
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
|
if snapshot == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cm := components.ChatMessage{
|
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
|
||||||
Nickname: uiMsg.Nickname,
|
|
||||||
Color: uiMsg.Color,
|
|
||||||
Message: uiMsg.Message,
|
|
||||||
Time: uiMsg.Time,
|
|
||||||
}
|
|
||||||
chatMu.Lock()
|
|
||||||
chatMessages = append(chatMessages, cm)
|
|
||||||
if len(chatMessages) > 50 {
|
|
||||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
|
||||||
}
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,9 +177,6 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
|
|||||||
}
|
}
|
||||||
|
|
||||||
gi.DropPiece(col, myColor)
|
gi.DropPiece(col, myColor)
|
||||||
|
|
||||||
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
|
|
||||||
// Return empty SSE response.
|
|
||||||
datastar.NewSSE(w, r)
|
datastar.NewSSE(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,22 +221,18 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cm := game.ChatMessage{
|
// Map color (1-based) to slot (0-based) for the unified chat message
|
||||||
|
msg := chat.Message{
|
||||||
Nickname: nick,
|
Nickname: nick,
|
||||||
Color: myColor,
|
Slot: myColor - 1,
|
||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
saveChatMessage(queries, gameID, cm)
|
chat.SaveMessage(queries, gameID, msg)
|
||||||
|
|
||||||
data, err := json.Marshal(cm)
|
room := chat.NewRoom(nc, "game.chat."+gameID, nil)
|
||||||
if err != nil {
|
room.Send(msg)
|
||||||
datastar.NewSSE(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
|
|
||||||
|
|
||||||
// Clear the chat input
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||||
}
|
}
|
||||||
@@ -314,61 +304,11 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendGameComponents patches all game-related SSE components.
|
// sendGameComponents patches all game-related SSE components.
|
||||||
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
|
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
|
|
||||||
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
|
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
|
||||||
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
|
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
|
||||||
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
|
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
|
||||||
|
sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
|
||||||
chatMu.Lock()
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat persistence helpers — inlined from the former ChatPersister.
|
|
||||||
|
|
||||||
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
|
|
||||||
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
|
|
||||||
GameID: gameID,
|
|
||||||
Nickname: msg.Nickname,
|
|
||||||
Color: int64(msg.Color),
|
|
||||||
Message: msg.Message,
|
|
||||||
CreatedAt: msg.Time,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
|
|
||||||
rows, err := queries.GetChatMessages(context.Background(), gameID)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msgs := make([]game.ChatMessage, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
msgs[i] = game.ChatMessage{
|
|
||||||
Nickname: r.Nickname,
|
|
||||||
Color: int(r.Color),
|
|
||||||
Message: r.Message,
|
|
||||||
Time: r.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DB returns newest-first; reverse for display
|
|
||||||
slices.Reverse(msgs)
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMsgs))
|
|
||||||
for i, m := range chatMsgs {
|
|
||||||
msgs[i] = components.ChatMessage{
|
|
||||||
Nickname: m.Nickname,
|
|
||||||
Color: m.Color,
|
|
||||||
Message: m.Message,
|
|
||||||
Time: m.Time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msgs
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/ryanhamamura/c4/chat"
|
||||||
|
chatcomponents "github.com/ryanhamamura/c4/chat/components"
|
||||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
"github.com/ryanhamamura/c4/features/c4game/components"
|
||||||
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
|
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/c4/features/common/layouts"
|
||||||
@@ -8,7 +10,7 @@ import (
|
|||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
|
templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||||
@layouts.Base("Connect 4") {
|
@layouts.Base("Connect 4") {
|
||||||
<main
|
<main
|
||||||
class="flex flex-col items-center gap-4 p-4"
|
class="flex flex-col items-center gap-4 p-4"
|
||||||
@@ -21,7 +23,7 @@ templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
|
|||||||
@components.StatusBanner(g, myColor)
|
@components.StatusBanner(g, myColor)
|
||||||
<div class="c4-game-area">
|
<div class="c4-game-area">
|
||||||
@components.Board(g, myColor)
|
@components.Board(g, myColor)
|
||||||
@components.Chat(messages, g.ID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
</div>
|
</div>
|
||||||
if g.Status == game.StatusWaitingForPlayer {
|
if g.Status == game.StatusWaitingForPlayer {
|
||||||
@components.InviteLink(g.ID)
|
@components.InviteLink(g.ID)
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/snake"
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Slot int `json:"slot"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
templ Chat(messages []ChatMessage, gameID string) {
|
|
||||||
<div id="snake-chat" class="snake-chat">
|
|
||||||
<div class="snake-chat-history">
|
|
||||||
for _, m := range messages {
|
|
||||||
<div class="snake-chat-msg">
|
|
||||||
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Slot)) }>
|
|
||||||
{ m.Nickname + ": " }
|
|
||||||
</span>
|
|
||||||
<span>{ m.Message }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="snake-chat-input" data-morph-ignore>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Chat..."
|
|
||||||
autocomplete="off"
|
|
||||||
data-bind="chatMsg"
|
|
||||||
data-on:keydown.stop=""
|
|
||||||
data-on:keydown.key_enter={ datastar.PostSSE("/snake/%s/chat", gameID) }
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-on:click={ datastar.PostSSE("/snake/%s/chat", gameID) }
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@chatAutoScroll()
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ chatAutoScroll() {
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var el = document.querySelector('.snake-chat-history');
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
|
||||||
.observe(el, {childList:true, subtree:true});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatColor(slot int) string {
|
|
||||||
if slot >= 0 && slot < len(snake.SnakeColors) {
|
|
||||||
return snake.SnakeColors[slot]
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
package snakegame
|
package snakegame
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"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/components"
|
||||||
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
||||||
"github.com/ryanhamamura/c4/sessions"
|
"github.com/ryanhamamura/c4/sessions"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"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 {
|
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
@@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
|
|||||||
|
|
||||||
// Auto-join if nickname exists and not already in game
|
// Auto-join if nickname exists and not already in game
|
||||||
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||||
player := &snake.Player{
|
p := &snake.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: nickname,
|
Nickname: nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
si.Join(player)
|
si.Join(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
mySlot := si.GetPlayerSlot(playerID)
|
mySlot := si.GetPlayerSlot(playerID)
|
||||||
|
|
||||||
if mySlot < 0 {
|
if mySlot < 0 {
|
||||||
// Not in game yet
|
|
||||||
isGuest := r.URL.Query().Get("guest") == "1"
|
isGuest := r.URL.Query().Get("guest") == "1"
|
||||||
if userID == "" && !isGuest {
|
if userID == "" && !isGuest {
|
||||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||||
@@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
|
|||||||
}
|
}
|
||||||
|
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
|
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)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
|||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
chatCfg := snakeChatConfig(gameID)
|
||||||
|
|
||||||
// Send initial render
|
// Send initial render
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
||||||
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
||||||
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
|
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
|
||||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
||||||
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
||||||
}
|
}
|
||||||
@@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
|||||||
// Chat subscription (multiplayer only)
|
// Chat subscription (multiplayer only)
|
||||||
var chatCh chan *nats.Msg
|
var chatCh chan *nats.Msg
|
||||||
var chatSub *nats.Subscription
|
var chatSub *nats.Subscription
|
||||||
var chatMessages []components.ChatMessage
|
var room *chat.Room
|
||||||
var chatMu sync.Mutex
|
|
||||||
|
|
||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
chatCh = make(chan *nats.Msg, 64)
|
room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
|
||||||
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
|
chatCh, chatSub, err = room.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
|||||||
if msg == nil {
|
if msg == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var cm components.ChatMessage
|
_, snapshot := room.Receive(msg.Data)
|
||||||
if err := json.Unmarshal(msg.Data, &cm); err != nil {
|
if snapshot == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
chatMu.Lock()
|
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
|
||||||
chatMessages = append(chatMessages, cm)
|
|
||||||
if len(chatMessages) > 50 {
|
|
||||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
|
||||||
}
|
|
||||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
||||||
copy(msgs, chatMessages)
|
|
||||||
chatMu.Unlock()
|
|
||||||
|
|
||||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
|
|||||||
}
|
}
|
||||||
|
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
cm := components.ChatMessage{
|
msg := chat.Message{
|
||||||
Nickname: sg.Players[slot].Nickname,
|
Nickname: sg.Players[slot].Nickname,
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(cm)
|
|
||||||
if err != nil {
|
room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
|
||||||
return
|
room.Send(msg)
|
||||||
}
|
|
||||||
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
|
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||||
@@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
|
|||||||
userID := sessions.GetUserID(sm, r)
|
userID := sessions.GetUserID(sm, r)
|
||||||
|
|
||||||
if si.GetPlayerSlot(playerID) < 0 {
|
if si.GetPlayerSlot(playerID) < 0 {
|
||||||
player := &snake.Player{
|
p := &snake.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
Nickname: signals.Nickname,
|
Nickname: signals.Nickname,
|
||||||
}
|
}
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
player.UserID = &userID
|
p.UserID = &userID
|
||||||
}
|
}
|
||||||
si.Join(player)
|
si.Join(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/c4/chat"
|
||||||
|
chatcomponents "github.com/ryanhamamura/c4/chat/components"
|
||||||
"github.com/ryanhamamura/c4/features/common/components"
|
"github.com/ryanhamamura/c4/features/common/components"
|
||||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
"github.com/ryanhamamura/c4/features/common/layouts"
|
||||||
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
|
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
|
||||||
@@ -26,7 +28,7 @@ func keydownScript(gameID string) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) {
|
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||||
@layouts.Base("Snake") {
|
@layouts.Base("Snake") {
|
||||||
<main
|
<main
|
||||||
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
||||||
@@ -43,13 +45,13 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
|
|||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
<div class="snake-game-area">
|
<div class="snake-game-area">
|
||||||
@snakecomponents.Board(sg)
|
@snakecomponents.Board(sg)
|
||||||
@snakecomponents.Chat(messages, gameID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
@snakecomponents.Board(sg)
|
@snakecomponents.Board(sg)
|
||||||
}
|
}
|
||||||
} else if sg.Mode == snake.ModeMultiplayer {
|
} else if sg.Mode == snake.ModeMultiplayer {
|
||||||
@snakecomponents.Chat(messages, gameID)
|
@chatcomponents.Chat(messages, chatCfg)
|
||||||
}
|
}
|
||||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||||
@snakecomponents.InviteLink(gameID)
|
@snakecomponents.InviteLink(gameID)
|
||||||
|
|||||||
@@ -69,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal([]byte(data), &g.WinningCells)
|
return json.Unmarshal([]byte(data), &g.WinningCells)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatMessage is the domain type for persisted C4 chat messages.
|
|
||||||
type ChatMessage struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Color int `json:"color"` // 1=Red, 2=Yellow
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user