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:
@@ -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; }
|
||||||
|
|||||||
@@ -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
66
main.go
@@ -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
63
ui/snakechat.go
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user