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
|
# APP_URL=http://localhost:7331
|
||||||
|
|
||||||
# Server port (defaults to 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";
|
@source not "./daisyui{,*}.mjs";
|
||||||
@plugin "./daisyui.mjs";
|
@plugin "./daisyui.mjs";
|
||||||
@plugin "./daisyui-theme.mjs" {
|
@plugin "./daisyui-theme.mjs" {
|
||||||
name: "connect4";
|
name: "stealth";
|
||||||
default: true;
|
default: true;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
@@ -53,7 +53,17 @@
|
|||||||
.cell { width: 48px; height: 48px; border-radius: 50%; background: #556; transition: background 0.2s; }
|
.cell { width: 48px; height: 48px; border-radius: 50%; background: #556; transition: background 0.2s; }
|
||||||
.cell.red { background: #4a2a3a; }
|
.cell.red { background: #4a2a3a; }
|
||||||
.cell.yellow { background: #2a4545; }
|
.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; }
|
.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 {
|
@keyframes pulse {
|
||||||
from { transform: scale(1); box-shadow: 0 0 4px rgba(0,0,0,0.15); }
|
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); }
|
to { transform: scale(1.03); box-shadow: 0 0 8px rgba(0,0,0,0.25); }
|
||||||
@@ -111,6 +121,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 +164,59 @@
|
|||||||
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; }
|
||||||
|
.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"
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID int64
|
||||||
|
GameID string
|
||||||
|
Nickname string
|
||||||
|
Color int64
|
||||||
|
Message string
|
||||||
|
CreatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID string
|
ID string
|
||||||
Board 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/db/gen"
|
"github.com/ryanhamamura/c4/db/gen"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/c4/game"
|
||||||
"github.com/ryanhamamura/c4/snake"
|
"github.com/ryanhamamura/c4/snake"
|
||||||
|
"github.com/ryanhamamura/c4/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GamePersister struct {
|
type GamePersister struct {
|
||||||
@@ -286,3 +288,40 @@ func (p *SnakePersister) DeleteSnakeGame(id string) error {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
return p.queries.DeleteSnakeGame(ctx, id)
|
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:
|
environment:
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
volumes:
|
volumes:
|
||||||
- c4-data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
volumes:
|
|
||||||
c4-data:
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
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
|
golang.org/x/crypto v0.47.0
|
||||||
modernc.org/sqlite v1.44.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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk=
|
||||||
github.com/ryanhamamura/via v0.15.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
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()
|
store = game.NewGameStore()
|
||||||
snakeStore = snake.NewSnakeStore()
|
snakeStore = snake.NewSnakeStore()
|
||||||
queries *gen.Queries
|
queries *gen.Queries
|
||||||
|
chatPersister *db.ChatPersister
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
@@ -61,6 +62,7 @@ func main() {
|
|||||||
queries = gen.New(db.DB)
|
queries = gen.New(db.DB)
|
||||||
store.SetPersister(db.NewGamePersister(queries))
|
store.SetPersister(db.NewGamePersister(queries))
|
||||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||||
|
chatPersister = db.NewChatPersister(queries)
|
||||||
|
|
||||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -251,6 +253,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Session().RenewToken()
|
||||||
c.Session().Set("user_id", user.ID)
|
c.Session().Set("user_id", user.ID)
|
||||||
c.Session().Set("username", user.Username)
|
c.Session().Set("username", user.Username)
|
||||||
c.Session().Set("nickname", user.Username)
|
c.Session().Set("nickname", user.Username)
|
||||||
@@ -319,6 +322,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Session().RenewToken()
|
||||||
c.Session().Set("user_id", user.ID)
|
c.Session().Set("user_id", user.ID)
|
||||||
c.Session().Set("username", user.Username)
|
c.Session().Set("username", user.Username)
|
||||||
c.Session().Set("nickname", user.Username)
|
c.Session().Set("nickname", user.Username)
|
||||||
@@ -353,6 +357,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("")
|
||||||
|
chatMessages, _ := chatPersister.LoadChatMessages(gameID)
|
||||||
|
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 +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 {
|
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 +533,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(),
|
||||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
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 {
|
||||||
@@ -674,7 +733,7 @@ func main() {
|
|||||||
var content []h.H
|
var content []h.H
|
||||||
content = append(content,
|
content = append(content,
|
||||||
ui.BackToLobby(),
|
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.SnakePlayerList(sg, mySlot),
|
||||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||||
)
|
)
|
||||||
@@ -709,14 +768,14 @@ func main() {
|
|||||||
wrapperAttrs := []h.H{
|
wrapperAttrs := []h.H{
|
||||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||||
via.OnKeyDownMap(
|
via.OnKeyDownMap(
|
||||||
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))),
|
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.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.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.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.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.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.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.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 {
|
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
|
||||||
var cols []h.H
|
var cols []h.H
|
||||||
|
|
||||||
|
activeTurn := 0
|
||||||
|
if g.Status == game.StatusInProgress {
|
||||||
|
activeTurn = g.CurrentTurn
|
||||||
|
}
|
||||||
|
|
||||||
for col := 0; col < 7; col++ {
|
for col := 0; col < 7; col++ {
|
||||||
var cells []h.H
|
var cells []h.H
|
||||||
for row := 0; row < 6; row++ {
|
for row := 0; row < 6; row++ {
|
||||||
cellColor := g.Board[row][col]
|
cellColor := g.Board[row][col]
|
||||||
isWinning := g.IsWinningCell(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
|
// 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...)
|
return h.Div(attrs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Cell(color int, isWinning bool) h.H {
|
func Cell(color int, isWinning, isActiveTurn bool) h.H {
|
||||||
class := "cell"
|
class := "cell"
|
||||||
switch color {
|
switch color {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -56,5 +62,8 @@ func Cell(color int, isWinning bool) h.H {
|
|||||||
if isWinning {
|
if isWinning {
|
||||||
class += " winning"
|
class += " winning"
|
||||||
}
|
}
|
||||||
|
if isActiveTurn {
|
||||||
|
class += " active-turn"
|
||||||
|
}
|
||||||
return h.Div(h.Class(class))
|
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 {
|
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 {
|
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"),
|
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||||
authSection,
|
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.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(connect4Class), h.Type("button"), StealthTitle(""), p.TabClickConnect4),
|
||||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("Snake"), p.TabClickSnake),
|
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("~~~~"), p.TabClickSnake),
|
||||||
),
|
),
|
||||||
tabContent,
|
tabContent,
|
||||||
)
|
)
|
||||||
@@ -65,7 +74,7 @@ func LobbyView(p LobbyProps) h.H {
|
|||||||
|
|
||||||
func connect4LobbyContent(p LobbyProps) h.H {
|
func connect4LobbyContent(p LobbyProps) h.H {
|
||||||
return h.Div(
|
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.Form(
|
||||||
h.FieldSet(h.Class("fieldset"),
|
h.FieldSet(h.Class("fieldset"),
|
||||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
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 != "" {
|
if url := os.Getenv("APP_URL"); url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
return "https://demo.adriatica.io"
|
return "https://games.adriatica.io"
|
||||||
}
|
}
|
||||||
|
|
||||||
func InviteLink(gameID string) h.H {
|
func InviteLink(gameID string) h.H {
|
||||||
|
|||||||
Reference in New Issue
Block a user