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.
This commit is contained in:
Ryan Hamamura
2026-02-05 10:15:26 -10:00
parent 0279615b36
commit 3d019fd948
4 changed files with 248 additions and 10 deletions

View File

@@ -101,10 +101,54 @@
} }
.snake-wrapper:focus { outline: none; } .snake-wrapper:focus { outline: none; }
/* Desaturate inline-styled snake body/head colors */
.snake-cell[style*="background"] { /* Snake game area: board + chat side-by-side */
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] { @media (max-width: 768px) {
box-shadow: none !important; .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; }

View File

@@ -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 { .flex {
display: flex; display: flex;
} }
@@ -1432,6 +1440,9 @@
.opacity-60 { .opacity-60 {
opacity: 60%; opacity: 60%;
} }
.opacity-70 {
opacity: 70%;
}
.btn-ghost { .btn-ghost {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -1607,11 +1618,67 @@
.snake-wrapper:focus { .snake-wrapper:focus {
outline: none; outline: none;
} }
.snake-cell[style*="background"] { .snake-game-area {
filter: saturate(0) brightness(0.85); display: flex;
gap: 16px;
align-items: flex-start;
justify-content: center;
} }
.snake-cell.snake-head[style] { @media (max-width: 768px) {
box-shadow: none !important; .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 { @layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {

66
main.go
View File

@@ -6,9 +6,12 @@ import (
"database/sql" "database/sql"
_ "embed" _ "embed"
"encoding/hex" "encoding/hex"
"encoding/json"
"log" "log"
"net/http" "net/http"
"os" "os"
"sync"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/joho/godotenv" "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 { if gameExists {
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() }) 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 // Auto-join if nickname exists
@@ -638,7 +684,25 @@ func main() {
) )
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { 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 // Only show invite link for multiplayer games

63
ui/snakechat.go Normal file
View File

@@ -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),
),
)
}