feat: add in-game chat to Connect 4
All checks were successful
Deploy c4 / deploy (push) Successful in 43s
All checks were successful
Deploy c4 / deploy (push) Successful in 43s
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.
This commit is contained in:
@@ -111,6 +111,8 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.snake-game-area { flex-direction: column; align-items: center; }
|
.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 */
|
/* Snake chat */
|
||||||
@@ -152,3 +154,56 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.snake-chat-input button:hover { background: #667; }
|
.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; }
|
||||||
|
|||||||
@@ -1647,6 +1647,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.snake-game-area .snake-chat {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.snake-chat-history {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.snake-chat {
|
.snake-chat {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1698,6 +1705,75 @@
|
|||||||
.snake-chat-input button:hover {
|
.snake-chat-input button:hover {
|
||||||
background: #667;
|
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 {
|
@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] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|||||||
56
main.go
56
main.go
@@ -353,6 +353,9 @@ func main() {
|
|||||||
nickname := c.Signal(sessionNickname)
|
nickname := c.Signal(sessionNickname)
|
||||||
colSignal := c.Signal(0)
|
colSignal := c.Signal(0)
|
||||||
showGuestPrompt := c.Signal(false)
|
showGuestPrompt := c.Signal(false)
|
||||||
|
chatMsg := c.Signal("")
|
||||||
|
var chatMessages []ui.C4ChatMessage
|
||||||
|
var chatMu sync.Mutex
|
||||||
|
|
||||||
goToLogin := c.Action(func() {
|
goToLogin := c.Action(func() {
|
||||||
c.Session().Set("return_url", "/game/"+gameID)
|
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 {
|
if gameExists {
|
||||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
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 {
|
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||||
@@ -480,13 +528,19 @@ func main() {
|
|||||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
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
|
var content []h.H
|
||||||
content = append(content,
|
content = append(content,
|
||||||
ui.BackToLobby(),
|
ui.BackToLobby(),
|
||||||
ui.StealthTitle("text-3xl font-bold"),
|
ui.StealthTitle("text-3xl font-bold"),
|
||||||
ui.PlayerInfo(g, myColor),
|
ui.PlayerInfo(g, myColor),
|
||||||
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
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 {
|
if g.Status == game.StatusWaitingForPlayer {
|
||||||
|
|||||||
64
ui/c4chat.go
Normal file
64
ui/c4chat.go
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user