Compare commits
18 Commits
427521505b
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b5f2b80c | ||
|
|
73b6e3bcc5 | ||
|
|
ffd44ae56b | ||
|
|
5a5cd08abb | ||
|
|
884650c68d | ||
|
|
c5b863efdd | ||
|
|
968c2cdb61 | ||
|
|
c541ba56d4 | ||
| 3593197271 | |||
|
|
08c20a1732 | ||
|
|
deff9b3859 | ||
|
|
645d958041 | ||
|
|
f238e126d3 | ||
|
|
9069530e47 | ||
|
|
e45559ecb3 | ||
|
|
e85271ab29 | ||
|
|
9799387a32 | ||
|
|
dfc2111be5 |
@@ -1,4 +1,4 @@
|
||||
# Application URL for invite links (defaults to https://demo.adriatica.io)
|
||||
# Application URL for invite links (defaults to https://games.adriatica.io)
|
||||
# APP_URL=http://localhost:7331
|
||||
|
||||
# Server port (defaults to 7331)
|
||||
|
||||
28
.gitea/workflows/deploy.yml
Normal file
28
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Deploy c4
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
DEPLOY_DIR: /home/ryan/c4
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: games
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync to deploy directory
|
||||
run: |
|
||||
mkdir -p $DEPLOY_DIR
|
||||
rsync -a --delete --exclude 'data/' . $DEPLOY_DIR/
|
||||
|
||||
- name: Ensure data directory exists with correct ownership
|
||||
run: |
|
||||
mkdir -p $DEPLOY_DIR/data
|
||||
# UID 5 / GID 60 = games:games in the container (debian:bookworm-slim)
|
||||
sudo chown 5:60 $DEPLOY_DIR/data
|
||||
|
||||
- name: Rebuild and restart
|
||||
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans
|
||||
@@ -3,7 +3,7 @@
|
||||
@source not "./daisyui{,*}.mjs";
|
||||
@plugin "./daisyui.mjs";
|
||||
@plugin "./daisyui-theme.mjs" {
|
||||
name: "connect4";
|
||||
name: "stealth";
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
|
||||
@@ -53,7 +53,17 @@
|
||||
.cell { width: 48px; height: 48px; border-radius: 50%; background: #556; transition: background 0.2s; }
|
||||
.cell.red { background: #4a2a3a; }
|
||||
.cell.yellow { background: #2a4545; }
|
||||
.cell.red.active-turn { animation: glow-red 1.5s ease-in-out infinite alternate; }
|
||||
.cell.yellow.active-turn { animation: glow-yellow 1.5s ease-in-out infinite alternate; }
|
||||
.cell.winning { animation: pulse 0.5s ease-in-out infinite alternate; }
|
||||
@keyframes glow-red {
|
||||
from { box-shadow: 0 0 4px rgba(74, 42, 58, 0.3); }
|
||||
to { box-shadow: 0 0 12px rgba(74, 42, 58, 0.7); }
|
||||
}
|
||||
@keyframes glow-yellow {
|
||||
from { box-shadow: 0 0 4px rgba(42, 69, 69, 0.3); }
|
||||
to { box-shadow: 0 0 12px rgba(42, 69, 69, 0.7); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
from { transform: scale(1); box-shadow: 0 0 4px rgba(0,0,0,0.15); }
|
||||
to { transform: scale(1.03); box-shadow: 0 0 8px rgba(0,0,0,0.25); }
|
||||
@@ -111,6 +121,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 +164,59 @@
|
||||
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; }
|
||||
.board { gap: 4px; padding: 8px; }
|
||||
.column { gap: 4px; padding: 2px; }
|
||||
.cell { width: 36px; height: 36px; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
71
db/gen/chat.sql.go
Normal file
71
db/gen/chat.sql.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: chat.sql
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createChatMessage = `-- name: CreateChatMessage :exec
|
||||
INSERT INTO chat_messages (game_id, nickname, color, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateChatMessageParams struct {
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createChatMessage,
|
||||
arg.GameID,
|
||||
arg.Nickname,
|
||||
arg.Color,
|
||||
arg.Message,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getChatMessages = `-- name: GetChatMessages :many
|
||||
SELECT id, game_id, nickname, color, message, created_at FROM chat_messages
|
||||
WHERE game_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMessage, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getChatMessages, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ChatMessage
|
||||
for rows.Next() {
|
||||
var i ChatMessage
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GameID,
|
||||
&i.Nickname,
|
||||
&i.Color,
|
||||
&i.Message,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int64
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board string
|
||||
|
||||
15
db/migrations/006_add_chat_messages.sql
Normal file
15
db/migrations/006_add_chat_messages.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_chat_messages_game ON chat_messages(game_id, created_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_chat_messages_game;
|
||||
DROP TABLE IF EXISTS chat_messages;
|
||||
@@ -3,10 +3,12 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
@@ -286,3 +288,40 @@ func (p *SnakePersister) DeleteSnakeGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteSnakeGame(ctx, id)
|
||||
}
|
||||
|
||||
type ChatPersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewChatPersister(q *gen.Queries) *ChatPersister {
|
||||
return &ChatPersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *ChatPersister) SaveChatMessage(gameID string, msg ui.C4ChatMessage) error {
|
||||
return p.queries.CreateChatMessage(context.Background(), gen.CreateChatMessageParams{
|
||||
GameID: gameID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Color),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ChatPersister) LoadChatMessages(gameID string) ([]ui.C4ChatMessage, error) {
|
||||
rows, err := p.queries.GetChatMessages(context.Background(), gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs := make([]ui.C4ChatMessage, len(rows))
|
||||
for i, r := range rows {
|
||||
msgs[i] = ui.C4ChatMessage{
|
||||
Nickname: r.Nickname,
|
||||
Color: int(r.Color),
|
||||
Message: r.Message,
|
||||
Time: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
// Query returns newest-first; reverse to oldest-first for display
|
||||
slices.Reverse(msgs)
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
9
db/queries/chat.sql
Normal file
9
db/queries/chat.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- name: CreateChatMessage :exec
|
||||
INSERT INTO chat_messages (game_id, nickname, color, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetChatMessages :many
|
||||
SELECT * FROM chat_messages
|
||||
WHERE game_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 50;
|
||||
@@ -11,7 +11,4 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
volumes:
|
||||
- c4-data:/app/data
|
||||
|
||||
volumes:
|
||||
c4-data:
|
||||
- ./data:/app/data
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/ryanhamamura/via v0.15.0
|
||||
github.com/ryanhamamura/via v0.23.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
modernc.org/sqlite v1.44.0
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@@ -78,8 +78,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/ryanhamamura/via v0.15.0 h1:f9ZMzWZQamu8MgdKiPPX6U8rIGfI3P3zVlmd/DTUUQ0=
|
||||
github.com/ryanhamamura/via v0.15.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
||||
github.com/ryanhamamura/via v0.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk=
|
||||
github.com/ryanhamamura/via v0.21.2/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
||||
github.com/ryanhamamura/via v0.23.0 h1:0e7nytisazcWq7uxs6T27GM3FwzosCMenkxJd+78Lko=
|
||||
github.com/ryanhamamura/via v0.23.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||
|
||||
81
main.go
81
main.go
@@ -30,6 +30,7 @@ var (
|
||||
store = game.NewGameStore()
|
||||
snakeStore = snake.NewSnakeStore()
|
||||
queries *gen.Queries
|
||||
chatPersister *db.ChatPersister
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
@@ -61,6 +62,7 @@ func main() {
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||
chatPersister = db.NewChatPersister(queries)
|
||||
|
||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||
if err != nil {
|
||||
@@ -251,6 +253,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
@@ -319,6 +322,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
@@ -353,6 +357,9 @@ func main() {
|
||||
nickname := c.Signal(sessionNickname)
|
||||
colSignal := c.Signal(0)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
chatMsg := c.Signal("")
|
||||
chatMessages, _ := chatPersister.LoadChatMessages(gameID)
|
||||
var chatMu sync.Mutex
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/game/"+gameID)
|
||||
@@ -434,8 +441,54 @@ 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(),
|
||||
}
|
||||
chatPersister.SaveChatMessage(gameID, cm)
|
||||
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 +533,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(),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
||||
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 {
|
||||
@@ -674,7 +733,7 @@ func main() {
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
ui.BackToLobby(),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("~~~~")),
|
||||
ui.SnakePlayerList(sg, mySlot),
|
||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||
)
|
||||
@@ -709,14 +768,14 @@ func main() {
|
||||
wrapperAttrs := []h.H{
|
||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||
via.OnKeyDownMap(
|
||||
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))),
|
||||
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))),
|
||||
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))),
|
||||
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))),
|
||||
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault()),
|
||||
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
13
ui/board.go
13
ui/board.go
@@ -11,12 +11,18 @@ type ColumnClickFn func(col int) h.H
|
||||
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
|
||||
var cols []h.H
|
||||
|
||||
activeTurn := 0
|
||||
if g.Status == game.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
for col := 0; col < 7; col++ {
|
||||
var cells []h.H
|
||||
for row := 0; row < 6; row++ {
|
||||
cellColor := g.Board[row][col]
|
||||
isWinning := g.IsWinningCell(row, col)
|
||||
cells = append(cells, Cell(cellColor, isWinning))
|
||||
isActiveTurn := cellColor != 0 && cellColor == activeTurn
|
||||
cells = append(cells, Cell(cellColor, isWinning, isActiveTurn))
|
||||
}
|
||||
|
||||
// Column is clickable only if it's player's turn and game is in progress
|
||||
@@ -45,7 +51,7 @@ func Column(colIdx int, cells []h.H, columnClick ColumnClickFn, canClick bool) h
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
|
||||
func Cell(color int, isWinning bool) h.H {
|
||||
func Cell(color int, isWinning, isActiveTurn bool) h.H {
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
@@ -56,5 +62,8 @@ func Cell(color int, isWinning bool) h.H {
|
||||
if isWinning {
|
||||
class += " winning"
|
||||
}
|
||||
if isActiveTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return h.Div(h.Class(class))
|
||||
}
|
||||
|
||||
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.DataIgnoreMorph(),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Attr("placeholder", "Chat..."),
|
||||
h.Attr("autocomplete", "off"),
|
||||
msgBind,
|
||||
sendKeyDown,
|
||||
),
|
||||
h.Button(h.Type("button"), h.Text("Send"), sendClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
19
ui/lobby.go
19
ui/lobby.go
@@ -26,7 +26,16 @@ type LobbyProps struct {
|
||||
}
|
||||
|
||||
func BackToLobby() h.H {
|
||||
return h.A(h.Class("link text-sm opacity-70"), h.Href("/"), h.Text("← Back to Lobby"))
|
||||
return h.A(h.Class("link text-sm opacity-70"), h.Href("/"), h.Text("← Back"))
|
||||
}
|
||||
|
||||
func StealthTitle(class string) h.H {
|
||||
return h.Span(h.Class(class),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
)
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
@@ -54,10 +63,10 @@ func LobbyView(p LobbyProps) h.H {
|
||||
|
||||
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||
authSection,
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), h.Text("Game Lobby")),
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), StealthTitle("")),
|
||||
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), h.Text("Connect 4"), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("Snake"), p.TabClickSnake),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), StealthTitle(""), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("~~~~"), p.TabClickSnake),
|
||||
),
|
||||
tabContent,
|
||||
)
|
||||
@@ -65,7 +74,7 @@ func LobbyView(p LobbyProps) h.H {
|
||||
|
||||
func connect4LobbyContent(p LobbyProps) h.H {
|
||||
return h.Div(
|
||||
h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
|
||||
h.P(h.Class("mb-4"), h.Text("Start a new session")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||
|
||||
@@ -121,7 +121,7 @@ func getBaseURL() string {
|
||||
if url := os.Getenv("APP_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
return "https://demo.adriatica.io"
|
||||
return "https://games.adriatica.io"
|
||||
}
|
||||
|
||||
func InviteLink(gameID string) h.H {
|
||||
|
||||
Reference in New Issue
Block a user