From 3d019fd9482d49bcbc036504bcfddf13b0d74a2d Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:15:26 -1000 Subject: [PATCH] feat: add snake multiplayer chat as sidebar with vivid player colors Move chat to the right of the board using a flex wrapper that stacks vertically on narrow screens. Restore original snake player colors by removing the desaturation filter added with the dark theme. --- assets/css/input.css | 54 ++++++++++++++++++++++++++++--- assets/css/output.css | 75 ++++++++++++++++++++++++++++++++++++++++--- main.go | 66 ++++++++++++++++++++++++++++++++++++- ui/snakechat.go | 63 ++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 ui/snakechat.go diff --git a/assets/css/input.css b/assets/css/input.css index a774e6e..4dfb473 100644 --- a/assets/css/input.css +++ b/assets/css/input.css @@ -101,10 +101,54 @@ } .snake-wrapper:focus { outline: none; } -/* Desaturate inline-styled snake body/head colors */ -.snake-cell[style*="background"] { - filter: saturate(0) brightness(0.85); + +/* Snake game area: board + chat side-by-side */ +.snake-game-area { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: center; } -.snake-cell.snake-head[style] { - box-shadow: none !important; +@media (max-width: 768px) { + .snake-game-area { flex-direction: column; align-items: center; } } + +/* Snake chat */ +.snake-chat { width: 100%; max-width: 480px; } +.snake-game-area .snake-chat { width: 260px; max-width: none; flex-shrink: 0; } +.snake-chat-history { + height: 300px; + overflow-y: auto; + background: #334; + border-radius: 8px 8px 0 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.snake-chat-msg { font-size: 0.85rem; line-height: 1.3; } +.snake-chat-input { + display: flex; + gap: 0; + background: #445; + border-radius: 0 0 8px 8px; + overflow: hidden; +} +.snake-chat-input input { + flex: 1; + padding: 6px 10px; + background: transparent; + border: none; + color: inherit; + outline: none; + font-size: 0.85rem; +} +.snake-chat-input button { + padding: 6px 14px; + background: #556; + border: none; + color: inherit; + cursor: pointer; + font-size: 0.85rem; +} +.snake-chat-input button:hover { background: #667; } diff --git a/assets/css/output.css b/assets/css/output.css index 60beb0a..8fc9d70 100644 --- a/assets/css/output.css +++ b/assets/css/output.css @@ -1252,6 +1252,14 @@ } } } + .chat { + @layer daisyui.l1.l2.l3 { + display: grid; + column-gap: calc(0.25rem * 3); + padding-block: calc(0.25rem * 1); + --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e"); + } + } .flex { display: flex; } @@ -1432,6 +1440,9 @@ .opacity-60 { opacity: 60%; } + .opacity-70 { + opacity: 70%; + } .btn-ghost { @layer daisyui.l1 { &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { @@ -1607,11 +1618,67 @@ .snake-wrapper:focus { outline: none; } -.snake-cell[style*="background"] { - filter: saturate(0) brightness(0.85); +.snake-game-area { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: center; } -.snake-cell.snake-head[style] { - box-shadow: none !important; +@media (max-width: 768px) { + .snake-game-area { + flex-direction: column; + align-items: center; + } +} +.snake-chat { + width: 100%; + max-width: 480px; +} +.snake-game-area .snake-chat { + width: 260px; + max-width: none; + flex-shrink: 0; +} +.snake-chat-history { + height: 300px; + overflow-y: auto; + background: #334; + border-radius: 8px 8px 0 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.snake-chat-msg { + font-size: 0.85rem; + line-height: 1.3; +} +.snake-chat-input { + display: flex; + gap: 0; + background: #445; + border-radius: 0 0 8px 8px; + overflow: hidden; +} +.snake-chat-input input { + flex: 1; + padding: 6px 10px; + background: transparent; + border: none; + color: inherit; + outline: none; + font-size: 0.85rem; +} +.snake-chat-input button { + padding: 6px 14px; + background: #556; + border: none; + color: inherit; + cursor: pointer; + font-size: 0.85rem; +} +.snake-chat-input button:hover { + background: #667; } @layer base { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { diff --git a/main.go b/main.go index 4fcf031..8ca0131 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,12 @@ import ( "database/sql" _ "embed" "encoding/hex" + "encoding/json" "log" "net/http" "os" + "sync" + "time" "github.com/google/uuid" "github.com/joho/godotenv" @@ -588,8 +591,51 @@ func main() { } }) + chatMsg := c.Signal("") + var chatMessages []ui.ChatMessage + var chatMu sync.Mutex + + sendChat := c.Action(func() { + msg := chatMsg.String() + if msg == "" || si == nil { + return + } + slot := si.GetPlayerSlot(playerID) + if slot < 0 { + return + } + cm := ui.ChatMessage{ + Nickname: si.GetGame().Players[slot].Nickname, + Slot: slot, + Message: msg, + Time: time.Now().UnixMilli(), + } + data, err := json.Marshal(cm) + if err != nil { + return + } + c.Publish("snake.chat."+gameID, data) + chatMsg.SetValue("") + }) + if gameExists { c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() }) + + if si.GetGame().Mode == snake.ModeMultiplayer { + c.Subscribe("snake.chat."+gameID, func(data []byte) { + var cm ui.ChatMessage + 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() + }) + } } // Auto-join if nickname exists @@ -638,7 +684,25 @@ func main() { ) if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { - content = append(content, ui.SnakeBoard(sg)) + board := ui.SnakeBoard(sg) + + if sg.Mode == snake.ModeMultiplayer { + chatMu.Lock() + msgs := make([]ui.ChatMessage, len(chatMessages)) + copy(msgs, chatMessages) + chatMu.Unlock() + chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")) + content = append(content, h.Div(h.Class("snake-game-area"), board, chat)) + } else { + content = append(content, board) + } + } else if sg.Mode == snake.ModeMultiplayer { + // Show chat even before game starts (waiting/countdown) + chatMu.Lock() + msgs := make([]ui.ChatMessage, len(chatMessages)) + copy(msgs, chatMessages) + chatMu.Unlock() + content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))) } // Only show invite link for multiplayer games diff --git a/ui/snakechat.go b/ui/snakechat.go new file mode 100644 index 0000000..66bc663 --- /dev/null +++ b/ui/snakechat.go @@ -0,0 +1,63 @@ +package ui + +import ( + "fmt" + + "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/via/h" +) + +type ChatMessage struct { + Nickname string `json:"nickname"` + Slot int `json:"slot"` + Message string `json:"message"` + Time int64 `json:"time"` +} + +func SnakeChat(messages []ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H { + var msgEls []h.H + for _, m := range messages { + color := "#666" + if m.Slot >= 0 && m.Slot < len(snake.SnakeColors) { + color = snake.SnakeColors[m.Slot] + } + msgEls = append(msgEls, h.Div(h.Class("snake-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)), + )) + } + + // Auto-scroll chat history to bottom on new messages + autoScroll := h.Script(h.Text(` +(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}); +})(); +`)) + + historyAttrs := []h.H{h.Class("snake-chat-history")} + historyAttrs = append(historyAttrs, msgEls...) + historyAttrs = append(historyAttrs, autoScroll) + + return h.Div(h.Class("snake-chat"), + h.Div(historyAttrs...), + h.Div(h.Class("snake-chat-input"), + h.Input( + h.Type("text"), + h.Attr("placeholder", "Chat..."), + h.Attr("autocomplete", "off"), + // Prevent key events from bubbling to the game's window-level handler + h.Attr("onkeydown", "event.stopPropagation()"), + msgBind, + sendKeyDown, + ), + h.Button(h.Type("button"), h.Text("Send"), sendClick), + ), + ) +}