From 9069530e47ba7c8a7f0030c6db528e886346993d Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:54:19 -1000 Subject: [PATCH] feat: add in-game chat to Connect 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add real-time chat alongside the game board, mirroring the snake chat implementation. Fix mobile layout for both C4 and snake chats — expand chat to full width and reduce history height on small screens. --- assets/css/input.css | 55 +++++++++++++++++++++++++++++++ assets/css/output.css | 76 +++++++++++++++++++++++++++++++++++++++++++ main.go | 56 ++++++++++++++++++++++++++++++- ui/c4chat.go | 64 ++++++++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 ui/c4chat.go diff --git a/assets/css/input.css b/assets/css/input.css index eb88a40..121c533 100644 --- a/assets/css/input.css +++ b/assets/css/input.css @@ -111,6 +111,8 @@ } @media (max-width: 768px) { .snake-game-area { flex-direction: column; align-items: center; } + .snake-game-area .snake-chat { width: 100%; max-width: 480px; } + .snake-chat-history { height: 150px; } } /* Snake chat */ @@ -152,3 +154,56 @@ font-size: 0.85rem; } .snake-chat-input button:hover { background: #667; } + +/* C4 game area: board + chat side-by-side */ +.c4-game-area { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: center; +} +@media (max-width: 768px) { + .c4-game-area { flex-direction: column; align-items: center; } + .c4-game-area .c4-chat { width: 100%; max-width: 480px; } + .c4-chat-history { height: 150px; } +} + +/* C4 chat */ +.c4-chat { width: 100%; max-width: 480px; } +.c4-game-area .c4-chat { width: 260px; max-width: none; flex-shrink: 0; } +.c4-chat-history { + height: 300px; + overflow-y: auto; + background: #334; + border-radius: 8px 8px 0 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.c4-chat-msg { font-size: 0.85rem; line-height: 1.3; } +.c4-chat-input { + display: flex; + gap: 0; + background: #445; + border-radius: 0 0 8px 8px; + overflow: hidden; +} +.c4-chat-input input { + flex: 1; + padding: 6px 10px; + background: transparent; + border: none; + color: inherit; + outline: none; + font-size: 0.85rem; +} +.c4-chat-input button { + padding: 6px 14px; + background: #556; + border: none; + color: inherit; + cursor: pointer; + font-size: 0.85rem; +} +.c4-chat-input button:hover { background: #667; } diff --git a/assets/css/output.css b/assets/css/output.css index 3f82689..0bdf756 100644 --- a/assets/css/output.css +++ b/assets/css/output.css @@ -1647,6 +1647,13 @@ flex-direction: column; align-items: center; } + .snake-game-area .snake-chat { + width: 100%; + max-width: 480px; + } + .snake-chat-history { + height: 150px; + } } .snake-chat { width: 100%; @@ -1698,6 +1705,75 @@ .snake-chat-input button:hover { background: #667; } +.c4-game-area { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: center; +} +@media (max-width: 768px) { + .c4-game-area { + flex-direction: column; + align-items: center; + } + .c4-game-area .c4-chat { + width: 100%; + max-width: 480px; + } + .c4-chat-history { + height: 150px; + } +} +.c4-chat { + width: 100%; + max-width: 480px; +} +.c4-game-area .c4-chat { + width: 260px; + max-width: none; + flex-shrink: 0; +} +.c4-chat-history { + height: 300px; + overflow-y: auto; + background: #334; + border-radius: 8px 8px 0 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.c4-chat-msg { + font-size: 0.85rem; + line-height: 1.3; +} +.c4-chat-input { + display: flex; + gap: 0; + background: #445; + border-radius: 0 0 8px 8px; + overflow: hidden; +} +.c4-chat-input input { + flex: 1; + padding: 6px 10px; + background: transparent; + border: none; + color: inherit; + outline: none; + font-size: 0.85rem; +} +.c4-chat-input button { + padding: 6px 14px; + background: #556; + border: none; + color: inherit; + cursor: pointer; + font-size: 0.85rem; +} +.c4-chat-input button:hover { + background: #667; +} @layer base { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { color-scheme: light; diff --git a/main.go b/main.go index 6eb9194..097bfb6 100644 --- a/main.go +++ b/main.go @@ -353,6 +353,9 @@ func main() { nickname := c.Signal(sessionNickname) colSignal := c.Signal(0) showGuestPrompt := c.Signal(false) + chatMsg := c.Signal("") + var chatMessages []ui.C4ChatMessage + var chatMu sync.Mutex goToLogin := c.Action(func() { c.Session().Set("return_url", "/game/"+gameID) @@ -434,8 +437,53 @@ func main() { } }) + sendChat := c.Action(func() { + msg := chatMsg.String() + if msg == "" || gi == nil { + return + } + color := gi.GetPlayerColor(playerID) + if color == 0 { + return + } + g := gi.GetGame() + nick := "" + for _, p := range g.Players { + if p != nil && p.ID == playerID { + nick = p.Nickname + break + } + } + cm := ui.C4ChatMessage{ + Nickname: nick, + Color: color, + Message: msg, + Time: time.Now().UnixMilli(), + } + data, err := json.Marshal(cm) + if err != nil { + return + } + c.Publish("game.chat."+gameID, data) + chatMsg.SetValue("") + }) + if gameExists { c.Subscribe("game."+gameID, func(data []byte) { c.Sync() }) + + c.Subscribe("game.chat."+gameID, func(data []byte) { + var cm ui.C4ChatMessage + if err := json.Unmarshal(data, &cm); err != nil { + return + } + chatMu.Lock() + chatMessages = append(chatMessages, cm) + if len(chatMessages) > 50 { + chatMessages = chatMessages[len(chatMessages)-50:] + } + chatMu.Unlock() + c.Sync() + }) } if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 { @@ -480,13 +528,19 @@ func main() { return dropPiece.OnClick(via.WithSignalInt(colSignal, col)) } + chatMu.Lock() + msgs := make([]ui.C4ChatMessage, len(chatMessages)) + copy(msgs, chatMessages) + chatMu.Unlock() + chat := ui.C4Chat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")) + var content []h.H content = append(content, ui.BackToLobby(), ui.StealthTitle("text-3xl font-bold"), ui.PlayerInfo(g, myColor), ui.StatusBanner(g, myColor, createRematch.OnClick()), - ui.BoardComponent(g, columnClick, myColor), + h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat), ) if g.Status == game.StatusWaitingForPlayer { diff --git a/ui/c4chat.go b/ui/c4chat.go new file mode 100644 index 0000000..0e61671 --- /dev/null +++ b/ui/c4chat.go @@ -0,0 +1,64 @@ +package ui + +import ( + "fmt" + + "github.com/ryanhamamura/via/h" +) + +type C4ChatMessage struct { + Nickname string `json:"nickname"` + Color int `json:"color"` // 1=Red, 2=Yellow + Message string `json:"message"` + Time int64 `json:"time"` +} + +var c4ChatColors = map[int]string{ + 1: "#4a2a3a", + 2: "#2a4545", +} + +func C4Chat(messages []C4ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H { + var msgEls []h.H + for _, m := range messages { + color := "#666" + if c, ok := c4ChatColors[m.Color]; ok { + color = c + } + msgEls = append(msgEls, h.Div(h.Class("c4-chat-msg"), + h.Span( + h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)), + h.Text(m.Nickname+": "), + ), + h.Span(h.Text(m.Message)), + )) + } + + autoScroll := h.Script(h.Text(` +(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}); +})(); +`)) + + historyAttrs := []h.H{h.Class("c4-chat-history")} + historyAttrs = append(historyAttrs, msgEls...) + historyAttrs = append(historyAttrs, autoScroll) + + return h.Div(h.Class("c4-chat"), + h.Div(historyAttrs...), + h.Div(h.Class("c4-chat-input"), + h.Input( + h.Type("text"), + h.Attr("placeholder", "Chat..."), + h.Attr("autocomplete", "off"), + msgBind, + sendKeyDown, + ), + h.Button(h.Type("button"), h.Text("Send"), sendClick), + ), + ) +}