Compare commits
28 Commits
20ed4807d9
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b5f2b80c | ||
|
|
73b6e3bcc5 | ||
|
|
ffd44ae56b | ||
|
|
5a5cd08abb | ||
|
|
884650c68d | ||
|
|
c5b863efdd | ||
|
|
968c2cdb61 | ||
|
|
c541ba56d4 | ||
| 3593197271 | |||
|
|
08c20a1732 | ||
|
|
deff9b3859 | ||
|
|
645d958041 | ||
|
|
f238e126d3 | ||
|
|
9069530e47 | ||
|
|
e45559ecb3 | ||
|
|
e85271ab29 | ||
|
|
9799387a32 | ||
|
|
dfc2111be5 | ||
|
|
427521505b | ||
|
|
b0449fbeb9 | ||
|
|
d2ed3cffd9 | ||
|
|
3d019fd948 | ||
|
|
0279615b36 | ||
|
|
73128dc119 | ||
|
|
9a3d1fd164 | ||
|
|
e239e948ae | ||
|
|
f454e0d220 | ||
|
|
7faf94fa6d |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
c4
|
||||||
|
c4.db
|
||||||
|
data/
|
||||||
|
deploy/
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
assets/css/output.css
|
||||||
|
c4-deploy-*.tar.gz
|
||||||
|
c4-deploy-*_b64*.txt
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Application URL for invite links (defaults to https://games.adriatica.io)
|
||||||
|
# APP_URL=http://localhost:7331
|
||||||
|
|
||||||
|
# Server port (defaults to 7331)
|
||||||
|
# PORT=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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
c4
|
c4
|
||||||
c4.db
|
c4.db
|
||||||
data/
|
data/
|
||||||
|
.env
|
||||||
|
|
||||||
# Deploy artifacts
|
# Deploy artifacts
|
||||||
c4-deploy-*.tar.gz
|
c4-deploy-*.tar.gz
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM golang:1.25.4-bookworm AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /c4 .
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates wget && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /c4 /usr/local/bin/c4
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir data && chown games:games data
|
||||||
|
|
||||||
|
USER games
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO /dev/null http://localhost:8080/
|
||||||
|
|
||||||
|
CMD ["c4"]
|
||||||
@@ -3,39 +3,39 @@
|
|||||||
@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;
|
||||||
|
|
||||||
/* Playful & bright — warm, casual board game feel */
|
/* Muted stealth — dark, subtle chroma */
|
||||||
--color-base-100: oklch(98% 0.005 90); /* warm white */
|
--color-base-100: oklch(78% 0.008 260);
|
||||||
--color-base-200: oklch(95% 0.01 90); /* light cream */
|
--color-base-200: oklch(72% 0.008 260);
|
||||||
--color-base-300: oklch(90% 0.015 85); /* warm gray border */
|
--color-base-300: oklch(64% 0.008 260);
|
||||||
--color-base-content: oklch(25% 0.02 50); /* dark warm text */
|
--color-base-content: oklch(22% 0.01 260);
|
||||||
|
|
||||||
--color-primary: oklch(60% 0.22 30); /* warm red — matches game pieces */
|
--color-primary: oklch(38% 0.02 260);
|
||||||
--color-primary-content: oklch(98% 0.005 90);
|
--color-primary-content: oklch(90% 0.006 260);
|
||||||
--color-secondary: oklch(82% 0.17 85); /* golden yellow — matches game pieces */
|
--color-secondary: oklch(52% 0.015 260);
|
||||||
--color-secondary-content: oklch(25% 0.02 50);
|
--color-secondary-content: oklch(22% 0.01 260);
|
||||||
--color-accent: oklch(65% 0.19 250); /* board blue */
|
--color-accent: oklch(48% 0.02 280);
|
||||||
--color-accent-content: oklch(98% 0.005 90);
|
--color-accent-content: oklch(90% 0.006 260);
|
||||||
--color-neutral: oklch(35% 0.03 260);
|
--color-neutral: oklch(35% 0.015 260);
|
||||||
--color-neutral-content: oklch(95% 0.01 90);
|
--color-neutral-content: oklch(88% 0.006 260);
|
||||||
|
|
||||||
--color-success: oklch(72% 0.19 155);
|
--color-success: oklch(52% 0.02 160);
|
||||||
--color-success-content: oklch(20% 0.04 155);
|
--color-success-content: oklch(22% 0.01 160);
|
||||||
--color-warning: oklch(82% 0.17 85);
|
--color-warning: oklch(58% 0.02 80);
|
||||||
--color-warning-content: oklch(25% 0.04 85);
|
--color-warning-content: oklch(28% 0.01 80);
|
||||||
--color-error: oklch(60% 0.22 30);
|
--color-error: oklch(45% 0.03 20);
|
||||||
--color-error-content: oklch(97% 0.01 30);
|
--color-error-content: oklch(90% 0.006 20);
|
||||||
--color-info: oklch(70% 0.15 240);
|
--color-info: oklch(48% 0.02 250);
|
||||||
--color-info-content: oklch(20% 0.04 240);
|
--color-info-content: oklch(22% 0.01 250);
|
||||||
|
|
||||||
--radius-selector: 0.5rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
--radius-box: 0.75rem;
|
--radius-box: 0.75rem;
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
--depth: 1;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,62 +43,180 @@
|
|||||||
.board {
|
.board {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #2563eb;
|
background: #334;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.column { display: flex; flex-direction: column; gap: 8px; padding: 4px; border-radius: 8px; }
|
.column { display: flex; flex-direction: column; gap: 8px; padding: 4px; border-radius: 8px; }
|
||||||
.column.clickable { cursor: pointer; }
|
.column.clickable { cursor: pointer; }
|
||||||
.column.clickable:hover { background: rgba(255,255,255,0.15); }
|
.column.clickable:hover { background: rgba(255,255,255,0.08); }
|
||||||
.cell { width: 48px; height: 48px; border-radius: 50%; background: #1e40af; transition: background 0.2s; }
|
.cell { width: 48px; height: 48px; border-radius: 50%; background: #556; transition: background 0.2s; }
|
||||||
.cell.red { background: #dc2626; }
|
.cell.red { background: #4a2a3a; }
|
||||||
.cell.yellow { background: #facc15; }
|
.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 pulse {
|
@keyframes glow-red {
|
||||||
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
|
from { box-shadow: 0 0 4px rgba(74, 42, 58, 0.3); }
|
||||||
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
|
to { box-shadow: 0 0 12px rgba(74, 42, 58, 0.7); }
|
||||||
}
|
}
|
||||||
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; }
|
@keyframes glow-yellow {
|
||||||
.player-chip.red { background: #dc2626; }
|
from { box-shadow: 0 0 4px rgba(42, 69, 69, 0.3); }
|
||||||
.player-chip.yellow { background: #facc15; }
|
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); }
|
||||||
|
}
|
||||||
|
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #445; }
|
||||||
|
.player-chip.red { background: #4a2a3a; }
|
||||||
|
.player-chip.yellow { background: #2a4545; }
|
||||||
|
|
||||||
/* Snake game */
|
/* Snake game */
|
||||||
.snake-board {
|
.snake-board {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
background: #1a1a2e;
|
background: #556;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 3px solid #333;
|
border: 3px solid #445;
|
||||||
}
|
}
|
||||||
.snake-row { display: contents; }
|
.snake-row { display: contents; }
|
||||||
.snake-cell {
|
.snake-cell {
|
||||||
background: #16213e;
|
background: #667;
|
||||||
border: 1px solid rgba(255,255,255,0.03);
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
transition: background 130ms ease-in-out, box-shadow 130ms ease-out;
|
|
||||||
}
|
}
|
||||||
.snake-cell.snake-head {
|
.snake-cell.snake-head {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
animation: head-pop 130ms ease-out;
|
animation: head-pop 50ms ease-out;
|
||||||
}
|
}
|
||||||
@keyframes head-pop {
|
@keyframes head-pop {
|
||||||
0% { transform: scale(0.6); }
|
0% { transform: scale(0.85); }
|
||||||
50% { transform: scale(1.08); }
|
|
||||||
100% { transform: scale(1); }
|
100% { transform: scale(1); }
|
||||||
}
|
}
|
||||||
.snake-cell.snake-food {
|
.snake-cell.snake-food {
|
||||||
background: #ff6b6b;
|
background: #334;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 6px rgba(255,107,107,0.6);
|
box-shadow: 0 0 3px rgba(0,0,0,0.2);
|
||||||
animation: food-pulse 1.2s ease-in-out infinite alternate;
|
animation: food-pulse 1.2s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
@keyframes food-pulse {
|
@keyframes food-pulse {
|
||||||
from { box-shadow: 0 0 6px rgba(255,107,107,0.4); transform: scale(0.85); }
|
from { box-shadow: 0 0 3px rgba(0,0,0,0.1); transform: scale(0.85); }
|
||||||
to { box-shadow: 0 0 12px rgba(255,107,107,0.9); transform: scale(1); }
|
to { box-shadow: 0 0 6px rgba(0,0,0,0.2); transform: scale(1); }
|
||||||
}
|
}
|
||||||
.snake-cell.snake-dead {
|
.snake-cell.snake-dead {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
filter: grayscale(0.5);
|
filter: grayscale(0.5);
|
||||||
transition: opacity 400ms ease-out, background 130ms ease-in-out;
|
transition: opacity 400ms ease-out;
|
||||||
}
|
}
|
||||||
.snake-wrapper:focus { outline: none; }
|
.snake-wrapper:focus { outline: none; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Snake game area: board + chat side-by-side */
|
||||||
|
.snake-game-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@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 */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const createGame = `-- name: CreateGame :one
|
const createGame = `-- name: CreateGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status)
|
INSERT INTO games (id, board, current_turn, status)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players
|
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGameParams struct {
|
type CreateGameParams struct {
|
||||||
@@ -45,6 +45,9 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActiveGames = `-- name: GetActiveGames :many
|
const getActiveGames = `-- name: GetActiveGames :many
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE game_type = 'connect4' AND status < 2
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||||
@@ -111,6 +114,9 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGame = `-- name: GetGame :one
|
const getGame = `-- name: GetGame :one
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE id = ?
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||||
@@ -146,6 +152,9 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -186,7 +195,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
||||||
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id, g.game_type, g.grid_width, g.grid_height, g.max_players FROM games g
|
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id, g.game_type, g.grid_width, g.grid_height, g.max_players, g.game_mode, g.score, g.snake_speed FROM games g
|
||||||
JOIN game_players gp ON g.id = gp.game_id
|
JOIN game_players gp ON g.id = gp.game_id
|
||||||
WHERE gp.user_id = ?
|
WHERE gp.user_id = ?
|
||||||
ORDER BY g.updated_at DESC
|
ORDER BY g.updated_at DESC
|
||||||
@@ -215,6 +224,9 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,6 +31,9 @@ type Game struct {
|
|||||||
GridWidth sql.NullInt64
|
GridWidth sql.NullInt64
|
||||||
GridHeight sql.NullInt64
|
GridHeight sql.NullInt64
|
||||||
MaxPlayers int64
|
MaxPlayers int64
|
||||||
|
GameMode int64
|
||||||
|
Score int64
|
||||||
|
SnakeSpeed int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePlayer struct {
|
type GamePlayer struct {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createSnakeGame = `-- name: CreateSnakeGame :one
|
const createSnakeGame = `-- name: CreateSnakeGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
|
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players
|
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateSnakeGameParams struct {
|
type CreateSnakeGameParams struct {
|
||||||
@@ -22,6 +22,8 @@ type CreateSnakeGameParams struct {
|
|||||||
Status int64
|
Status int64
|
||||||
GridWidth sql.NullInt64
|
GridWidth sql.NullInt64
|
||||||
GridHeight sql.NullInt64
|
GridHeight sql.NullInt64
|
||||||
|
GameMode int64
|
||||||
|
SnakeSpeed int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
||||||
@@ -31,6 +33,8 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
|
|||||||
arg.Status,
|
arg.Status,
|
||||||
arg.GridWidth,
|
arg.GridWidth,
|
||||||
arg.GridHeight,
|
arg.GridHeight,
|
||||||
|
arg.GameMode,
|
||||||
|
arg.SnakeSpeed,
|
||||||
)
|
)
|
||||||
var i Game
|
var i Game
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -47,6 +51,9 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,7 @@ func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE game_type = 'snake' AND status < 2
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||||
@@ -113,6 +120,9 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -128,7 +138,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSnakeGame = `-- name: GetSnakeGame :one
|
const getSnakeGame = `-- name: GetSnakeGame :one
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players FROM games WHERE id = ? AND game_type = 'snake'
|
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake'
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||||
@@ -148,6 +158,9 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
|||||||
&i.GridWidth,
|
&i.GridWidth,
|
||||||
&i.GridHeight,
|
&i.GridHeight,
|
||||||
&i.MaxPlayers,
|
&i.MaxPlayers,
|
||||||
|
&i.GameMode,
|
||||||
|
&i.Score,
|
||||||
|
&i.SnakeSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -239,7 +252,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
|||||||
|
|
||||||
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND game_type = 'snake'
|
WHERE id = ? AND game_type = 'snake'
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -248,6 +261,7 @@ type UpdateSnakeGameParams struct {
|
|||||||
Status int64
|
Status int64
|
||||||
WinnerUserID sql.NullString
|
WinnerUserID sql.NullString
|
||||||
RematchGameID sql.NullString
|
RematchGameID sql.NullString
|
||||||
|
Score int64
|
||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +271,7 @@ func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams
|
|||||||
arg.Status,
|
arg.Status,
|
||||||
arg.WinnerUserID,
|
arg.WinnerUserID,
|
||||||
arg.RematchGameID,
|
arg.RematchGameID,
|
||||||
|
arg.Score,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
7
db/migrations/004_add_snake_mode.sql
Normal file
7
db/migrations/004_add_snake_mode.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE games ADD COLUMN game_mode INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE games ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE games DROP COLUMN score;
|
||||||
|
ALTER TABLE games DROP COLUMN game_mode;
|
||||||
5
db/migrations/005_add_snake_speed.sql
Normal file
5
db/migrations/005_add_snake_speed.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE games ADD COLUMN snake_speed INTEGER NOT NULL DEFAULT 7;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE games DROP COLUMN snake_speed;
|
||||||
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 {
|
||||||
@@ -171,6 +173,8 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
|||||||
Status: int64(sg.Status),
|
Status: int64(sg.Status),
|
||||||
GridWidth: gridWidth,
|
GridWidth: gridWidth,
|
||||||
GridHeight: gridHeight,
|
GridHeight: gridHeight,
|
||||||
|
GameMode: int64(sg.Mode),
|
||||||
|
SnakeSpeed: int64(sg.Speed),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -193,6 +197,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
|||||||
Status: int64(sg.Status),
|
Status: int64(sg.Status),
|
||||||
WinnerUserID: winnerUserID,
|
WinnerUserID: winnerUserID,
|
||||||
RematchGameID: rematchGameID,
|
RematchGameID: rematchGameID,
|
||||||
|
Score: int64(sg.Score),
|
||||||
ID: sg.ID,
|
ID: sg.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,9 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
|||||||
State: state,
|
State: state,
|
||||||
Players: make([]*snake.Player, 8),
|
Players: make([]*snake.Player, 8),
|
||||||
Status: snake.Status(row.Status),
|
Status: snake.Status(row.Status),
|
||||||
|
Mode: snake.GameMode(row.GameMode),
|
||||||
|
Score: int(row.Score),
|
||||||
|
Speed: int(row.SnakeSpeed),
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.RematchGameID.Valid {
|
if row.RematchGameID.Valid {
|
||||||
@@ -280,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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
-- name: CreateSnakeGame :one
|
-- name: CreateSnakeGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
|
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSnakeGame :one
|
-- name: GetSnakeGame :one
|
||||||
@@ -8,14 +8,14 @@ SELECT * FROM games WHERE id = ? AND game_type = 'snake';
|
|||||||
|
|
||||||
-- name: UpdateSnakeGame :exec
|
-- name: UpdateSnakeGame :exec
|
||||||
UPDATE games
|
UPDATE games
|
||||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND game_type = 'snake';
|
WHERE id = ? AND game_type = 'snake';
|
||||||
|
|
||||||
-- name: DeleteSnakeGame :exec
|
-- name: DeleteSnakeGame :exec
|
||||||
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
||||||
|
|
||||||
-- name: GetActiveSnakeGames :many
|
-- name: GetActiveSnakeGames :many
|
||||||
SELECT * FROM games WHERE game_type = 'snake' AND status < 2;
|
SELECT * FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0;
|
||||||
|
|
||||||
-- name: CreateSnakePlayer :exec
|
-- name: CreateSnakePlayer :exec
|
||||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||||
|
|||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
c4:
|
||||||
|
build: .
|
||||||
|
container_name: c4
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
5
go.mod
5
go.mod
@@ -4,10 +4,10 @@ go 1.25.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
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.4.0
|
github.com/ryanhamamura/via v0.23.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
maragu.dev/gomponents v1.2.0
|
|
||||||
modernc.org/sqlite v1.44.0
|
modernc.org/sqlite v1.44.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ require (
|
|||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
maragu.dev/gomponents v1.2.0 // indirect
|
||||||
modernc.org/libc v1.67.4 // indirect
|
modernc.org/libc v1.67.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -33,6 +33,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
|
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
|
||||||
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
@@ -76,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.4.0 h1:/8gfjcPhTl+SEYTPF+Guc6qB2vuW+FtNRQv+HpkV2k8=
|
github.com/ryanhamamura/via v0.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk=
|
||||||
github.com/ryanhamamura/via v0.4.0/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A=
|
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=
|
||||||
|
|||||||
283
main.go
283
main.go
@@ -2,13 +2,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/auth"
|
"github.com/ryanhamamura/c4/auth"
|
||||||
"github.com/ryanhamamura/c4/db"
|
"github.com/ryanhamamura/c4/db"
|
||||||
@@ -18,24 +24,23 @@ import (
|
|||||||
"github.com/ryanhamamura/c4/ui"
|
"github.com/ryanhamamura/c4/ui"
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
"github.com/ryanhamamura/via/vianats"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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/css/output.css
|
//go:embed assets
|
||||||
var daisyUICSS []byte
|
var assets embed.FS
|
||||||
|
|
||||||
func DaisyUIPlugin(v *via.V) {
|
func DaisyUIPlugin(v *via.V) {
|
||||||
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
css, _ := fs.ReadFile(assets, "assets/css/output.css")
|
||||||
w.Header().Set("Content-Type", "text/css")
|
sum := md5.Sum(css)
|
||||||
_, _ = w.Write(daisyUICSS)
|
version := hex.EncodeToString(sum[:4])
|
||||||
})
|
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version)))
|
||||||
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/_plugins/daisyui/style.css")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func port() string {
|
func port() string {
|
||||||
@@ -46,36 +51,39 @@ func port() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := db.Init("c4.db"); err != nil {
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Init("data/c4.db"); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ns, err := vianats.New(ctx, "./data/nats")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
store.SetPubSub(ns)
|
|
||||||
snakeStore.SetPubSub(ns)
|
|
||||||
|
|
||||||
v := via.New()
|
v := via.New()
|
||||||
v.Config(via.Options{
|
v.Config(via.Options{
|
||||||
LogLevel: via.LogLevelDebug,
|
LogLevel: via.LogLevelDebug,
|
||||||
DocumentTitle: "Game Lobby",
|
DocumentTitle: "Game Lobby",
|
||||||
ServerAddress: ":" + port(),
|
ServerAddress: ":" + port(),
|
||||||
SessionManager: sessionManager,
|
SessionManager: sessionManager,
|
||||||
PubSub: ns,
|
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
subFS, _ := fs.Sub(assets, "assets")
|
||||||
|
v.StaticFS("/assets/", subFS)
|
||||||
|
|
||||||
|
store.SetPubSub(v.PubSub())
|
||||||
|
snakeStore.SetPubSub(v.PubSub())
|
||||||
|
|
||||||
// Home page - tabbed lobby
|
// Home page - tabbed lobby
|
||||||
v.Page("/", func(c *via.Context) {
|
v.Page("/", func(c *via.Context) {
|
||||||
userID := c.Session().GetString("user_id")
|
userID := c.Session().GetString("user_id")
|
||||||
@@ -149,37 +157,73 @@ func main() {
|
|||||||
snakeNickname = c.Signal(username)
|
snakeNickname = c.Signal(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snake create game actions — one per preset
|
// Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
|
||||||
var snakePresetClicks []h.H
|
selectedSpeedIndex := c.Signal(1)
|
||||||
|
|
||||||
|
// Speed selector actions
|
||||||
|
var speedSelectClicks []h.H
|
||||||
|
for i := range snake.SpeedPresets {
|
||||||
|
idx := i
|
||||||
|
speedSelectClicks = append(speedSelectClicks, c.Action(func() {
|
||||||
|
selectedSpeedIndex.SetValue(idx)
|
||||||
|
c.Sync()
|
||||||
|
}).OnClick())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snake create game actions — one per preset for solo and multiplayer
|
||||||
|
var snakeSoloClicks []h.H
|
||||||
|
var snakeMultiClicks []h.H
|
||||||
for _, preset := range snake.GridPresets {
|
for _, preset := range snake.GridPresets {
|
||||||
w, ht := preset.Width, preset.Height
|
w, ht := preset.Width, preset.Height
|
||||||
snakePresetClicks = append(snakePresetClicks, c.Action(func() {
|
snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
|
||||||
name := snakeNickname.String()
|
name := snakeNickname.String()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Session().Set("nickname", name)
|
c.Session().Set("nickname", name)
|
||||||
si := snakeStore.Create(w, ht)
|
speedIdx := selectedSpeedIndex.Int()
|
||||||
|
speed := snake.DefaultSpeed
|
||||||
|
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||||
|
speed = snake.SpeedPresets[speedIdx].Speed
|
||||||
|
}
|
||||||
|
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed)
|
||||||
|
c.Redirectf("/snake/%s", si.ID())
|
||||||
|
}).OnClick())
|
||||||
|
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
|
||||||
|
name := snakeNickname.String()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Session().Set("nickname", name)
|
||||||
|
speedIdx := selectedSpeedIndex.Int()
|
||||||
|
speed := snake.DefaultSpeed
|
||||||
|
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||||
|
speed = snake.SpeedPresets[speedIdx].Speed
|
||||||
|
}
|
||||||
|
si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed)
|
||||||
c.Redirectf("/snake/%s", si.ID())
|
c.Redirectf("/snake/%s", si.ID())
|
||||||
}).OnClick())
|
}).OnClick())
|
||||||
}
|
}
|
||||||
|
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
return ui.LobbyView(ui.LobbyProps{
|
return ui.LobbyView(ui.LobbyProps{
|
||||||
NicknameBind: nickname.Bind(),
|
NicknameBind: nickname.Bind(),
|
||||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||||
CreateGameClick: createGame.OnClick(),
|
CreateGameClick: createGame.OnClick(),
|
||||||
IsLoggedIn: isLoggedIn,
|
IsLoggedIn: isLoggedIn,
|
||||||
Username: username,
|
Username: username,
|
||||||
LogoutClick: logout.OnClick(),
|
LogoutClick: logout.OnClick(),
|
||||||
UserGames: userGames,
|
UserGames: userGames,
|
||||||
DeleteGameClick: deleteGame,
|
DeleteGameClick: deleteGame,
|
||||||
ActiveTab: activeTab.String(),
|
ActiveTab: activeTab.String(),
|
||||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||||
TabClickSnake: tabClickSnake.OnClick(),
|
TabClickSnake: tabClickSnake.OnClick(),
|
||||||
SnakeNicknameBind: snakeNickname.Bind(),
|
SnakeNicknameBind: snakeNickname.Bind(),
|
||||||
SnakePresetClicks: snakePresetClicks,
|
SnakeSoloClicks: snakeSoloClicks,
|
||||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
SnakeMultiClicks: snakeMultiClicks,
|
||||||
|
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||||
|
SelectedSpeedIndex: selectedSpeedIndex.Int(),
|
||||||
|
SpeedSelectClicks: speedSelectClicks,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -209,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)
|
||||||
@@ -277,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)
|
||||||
@@ -311,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)
|
||||||
@@ -392,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 {
|
||||||
@@ -438,12 +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,
|
||||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
ui.BackToLobby(),
|
||||||
|
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 {
|
||||||
@@ -544,8 +646,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
|
||||||
@@ -587,30 +732,50 @@ func main() {
|
|||||||
|
|
||||||
var content []h.H
|
var content []h.H
|
||||||
content = append(content,
|
content = append(content,
|
||||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
|
ui.BackToLobby(),
|
||||||
|
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()),
|
||||||
)
|
)
|
||||||
|
|
||||||
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")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
// Only show invite link for multiplayer games
|
||||||
|
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
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)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,12 @@ func RemoveFood(state *GameState, indices []int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SpawnFood adds food items to maintain the target count.
|
// SpawnFood adds food items to maintain the target count.
|
||||||
func SpawnFood(state *GameState, playerCount int) {
|
// Single player always maintains exactly 1 food for classic snake feel.
|
||||||
|
func SpawnFood(state *GameState, playerCount int, mode GameMode) {
|
||||||
target := playerCount/2 + 1
|
target := playerCount/2 + 1
|
||||||
|
if mode == ModeSinglePlayer {
|
||||||
|
target = 1
|
||||||
|
}
|
||||||
for len(state.Food) < target {
|
for len(state.Food) < target {
|
||||||
p := randomEmptyCell(state)
|
p := randomEmptyCell(state)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
@@ -5,17 +5,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
targetFPS = 60
|
targetFPS = 60
|
||||||
tickInterval = time.Second / targetFPS
|
tickInterval = time.Second / targetFPS
|
||||||
snakeSpeed = 7 // cells per second
|
countdownSecondsMultiplayer = 10
|
||||||
moveInterval = time.Second / snakeSpeed
|
countdownSecondsSinglePlayer = 3
|
||||||
countdownSeconds = 10
|
inactivityLimit = 60 * time.Second
|
||||||
inactivityLimit = 60 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
||||||
si.game.Status = StatusCountdown
|
si.game.Status = StatusCountdown
|
||||||
si.game.CountdownEnd = time.Now().Add(countdownSeconds * time.Second)
|
|
||||||
|
countdown := countdownSecondsMultiplayer
|
||||||
|
if si.game.Mode == ModeSinglePlayer {
|
||||||
|
countdown = countdownSecondsSinglePlayer
|
||||||
|
}
|
||||||
|
si.game.CountdownEnd = time.Now().Add(time.Duration(countdown) * time.Second)
|
||||||
|
|
||||||
si.loopOnce.Do(func() {
|
si.loopOnce.Do(func() {
|
||||||
si.stopCh = make(chan struct{})
|
si.stopCh = make(chan struct{})
|
||||||
@@ -86,7 +90,7 @@ func (si *SnakeGameInstance) initGame() {
|
|||||||
|
|
||||||
state := si.game.State
|
state := si.game.State
|
||||||
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
|
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
|
||||||
SpawnFood(state, len(activeSlots))
|
SpawnFood(state, len(activeSlots), si.game.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) gamePhase() {
|
func (si *SnakeGameInstance) gamePhase() {
|
||||||
@@ -108,18 +112,13 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pending directions every tick for responsive input
|
// Track input activity for inactivity timeout
|
||||||
inputReceived := false
|
|
||||||
for i := 0; i < 8; i++ {
|
for i := 0; i < 8; i++ {
|
||||||
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
if len(si.pendingDirQueue[i]) > 0 {
|
||||||
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
|
lastInput = time.Now()
|
||||||
si.pendingDir[i] = nil
|
break
|
||||||
inputReceived = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if inputReceived {
|
|
||||||
lastInput = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inactivity timeout
|
// Inactivity timeout
|
||||||
if time.Since(lastInput) > inactivityLimit {
|
if time.Since(lastInput) > inactivityLimit {
|
||||||
@@ -132,7 +131,14 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only advance game state at snakeSpeed
|
// Compute move interval from per-game speed
|
||||||
|
speed := si.game.Speed
|
||||||
|
if speed <= 0 {
|
||||||
|
speed = DefaultSpeed
|
||||||
|
}
|
||||||
|
moveInterval := time.Second / time.Duration(speed)
|
||||||
|
|
||||||
|
// Only advance game state at game speed
|
||||||
moveAccum += tickInterval
|
moveAccum += tickInterval
|
||||||
if moveAccum < moveInterval {
|
if moveAccum < moveInterval {
|
||||||
si.gameMu.Unlock()
|
si.gameMu.Unlock()
|
||||||
@@ -140,6 +146,14 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
}
|
}
|
||||||
moveAccum -= moveInterval
|
moveAccum -= moveInterval
|
||||||
|
|
||||||
|
// Pop one direction from queue per movement frame
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
if len(si.pendingDirQueue[i]) > 0 && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
||||||
|
si.game.State.Snakes[i].Dir = si.pendingDirQueue[i][0]
|
||||||
|
si.pendingDirQueue[i] = si.pendingDirQueue[i][1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state := si.game.State
|
state := si.game.State
|
||||||
|
|
||||||
// Advance snakes
|
// Advance snakes
|
||||||
@@ -152,16 +166,33 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
// Check food eaten (only by surviving snakes)
|
// Check food eaten (only by surviving snakes)
|
||||||
eaten := CheckFood(state)
|
eaten := CheckFood(state)
|
||||||
RemoveFood(state, eaten)
|
RemoveFood(state, eaten)
|
||||||
SpawnFood(state, si.game.PlayerCount())
|
SpawnFood(state, si.game.PlayerCount(), si.game.Mode)
|
||||||
|
|
||||||
|
// Track score for single player
|
||||||
|
si.game.Score += len(eaten)
|
||||||
|
|
||||||
// Check game over
|
// Check game over
|
||||||
alive := AliveCount(state)
|
alive := AliveCount(state)
|
||||||
if alive <= 1 {
|
gameOver := false
|
||||||
si.game.Status = StatusFinished
|
if si.game.Mode == ModeSinglePlayer {
|
||||||
winnerIdx := LastAlive(state)
|
// Single player ends when the player dies (alive == 0)
|
||||||
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
if alive == 0 {
|
||||||
si.game.Winner = si.game.Players[winnerIdx]
|
gameOver = true
|
||||||
|
// No winner in single player - just final score
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Multiplayer ends when 1 or fewer alive
|
||||||
|
if alive <= 1 {
|
||||||
|
gameOver = true
|
||||||
|
winnerIdx := LastAlive(state)
|
||||||
|
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
||||||
|
si.game.Winner = si.game.Players[winnerIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gameOver {
|
||||||
|
si.game.Status = StatusFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
if si.persister != nil {
|
if si.persister != nil {
|
||||||
@@ -171,7 +202,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
si.gameMu.Unlock()
|
si.gameMu.Unlock()
|
||||||
si.notify()
|
si.notify()
|
||||||
|
|
||||||
if alive <= 1 {
|
if gameOver {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
|
func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *SnakeGameInstance {
|
||||||
|
if speed <= 0 {
|
||||||
|
speed = DefaultSpeed
|
||||||
|
}
|
||||||
id := generateID(4)
|
id := generateID(4)
|
||||||
sg := &SnakeGame{
|
sg := &SnakeGame{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -57,6 +60,8 @@ func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
|
|||||||
},
|
},
|
||||||
Players: make([]*Player, 8),
|
Players: make([]*Player, 8),
|
||||||
Status: StatusWaitingForPlayers,
|
Status: StatusWaitingForPlayers,
|
||||||
|
Mode: mode,
|
||||||
|
Speed: speed,
|
||||||
}
|
}
|
||||||
si := &SnakeGameInstance{
|
si := &SnakeGameInstance{
|
||||||
game: sg,
|
game: sg,
|
||||||
@@ -134,8 +139,8 @@ func (ss *SnakeStore) Delete(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveGames returns metadata of games that can be joined.
|
// ActiveGames returns metadata of multiplayer games that can be joined.
|
||||||
// Copies game data to avoid holding nested locks.
|
// Single player games are excluded. Copies game data to avoid holding nested locks.
|
||||||
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||||
ss.gamesMu.RLock()
|
ss.gamesMu.RLock()
|
||||||
instances := make([]*SnakeGameInstance, 0, len(ss.games))
|
instances := make([]*SnakeGameInstance, 0, len(ss.games))
|
||||||
@@ -148,7 +153,7 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
|||||||
for _, si := range instances {
|
for _, si := range instances {
|
||||||
si.gameMu.RLock()
|
si.gameMu.RLock()
|
||||||
g := si.game
|
g := si.game
|
||||||
if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown {
|
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
|
||||||
games = append(games, g)
|
games = append(games, g)
|
||||||
}
|
}
|
||||||
si.gameMu.RUnlock()
|
si.gameMu.RUnlock()
|
||||||
@@ -157,14 +162,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SnakeGameInstance struct {
|
type SnakeGameInstance struct {
|
||||||
game *SnakeGame
|
game *SnakeGame
|
||||||
gameMu sync.RWMutex
|
gameMu sync.RWMutex
|
||||||
pendingDir [8]*Direction
|
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
|
||||||
notify func()
|
notify func()
|
||||||
persister Persister
|
persister Persister
|
||||||
store *SnakeStore
|
store *SnakeStore
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
loopOnce sync.Once
|
loopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) ID() string {
|
func (si *SnakeGameInstance) ID() string {
|
||||||
@@ -220,15 +225,18 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
|
|||||||
|
|
||||||
si.notify()
|
si.notify()
|
||||||
|
|
||||||
if si.game.PlayerCount() >= 2 {
|
// Single player starts countdown immediately when 1 player joins
|
||||||
|
if si.game.Mode == ModeSinglePlayer && si.game.PlayerCount() >= 1 {
|
||||||
|
si.startOrResetCountdownLocked()
|
||||||
|
} else if si.game.Mode == ModeMultiplayer && si.game.PlayerCount() >= 2 {
|
||||||
si.startOrResetCountdownLocked()
|
si.startOrResetCountdownLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDirection buffers a direction change for the given slot.
|
// SetDirection queues a direction change for the given slot.
|
||||||
// The write happens under the game lock to avoid a data race with the game loop.
|
// Validates against the last queued direction (or current snake dir) to prevent 180° turns.
|
||||||
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
||||||
if slot < 0 || slot >= 8 {
|
if slot < 0 || slot >= 8 {
|
||||||
return
|
return
|
||||||
@@ -236,13 +244,28 @@ func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
|||||||
si.gameMu.Lock()
|
si.gameMu.Lock()
|
||||||
defer si.gameMu.Unlock()
|
defer si.gameMu.Unlock()
|
||||||
|
|
||||||
if si.game.State != nil && slot < len(si.game.State.Snakes) {
|
if si.game.State == nil || slot >= len(si.game.State.Snakes) {
|
||||||
s := si.game.State.Snakes[slot]
|
return
|
||||||
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
si.pendingDir[slot] = &dir
|
s := si.game.State.Snakes[slot]
|
||||||
|
if s == nil || !s.Alive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate against last queued direction, or current snake direction if queue empty
|
||||||
|
refDir := s.Dir
|
||||||
|
if len(si.pendingDirQueue[slot]) > 0 {
|
||||||
|
refDir = si.pendingDirQueue[slot][len(si.pendingDirQueue[slot])-1]
|
||||||
|
}
|
||||||
|
if !ValidateDirection(refDir, dir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap queue at 3 to prevent unbounded growth
|
||||||
|
if len(si.pendingDirQueue[slot]) >= 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
si.pendingDirQueue[slot] = append(si.pendingDirQueue[slot], dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) Stop() {
|
func (si *SnakeGameInstance) Stop() {
|
||||||
@@ -267,9 +290,11 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
|||||||
// (which acquires gamesMu) to avoid lock ordering deadlock.
|
// (which acquires gamesMu) to avoid lock ordering deadlock.
|
||||||
width := si.game.State.Width
|
width := si.game.State.Width
|
||||||
height := si.game.State.Height
|
height := si.game.State.Height
|
||||||
|
mode := si.game.Mode
|
||||||
|
speed := si.game.Speed
|
||||||
si.gameMu.Unlock()
|
si.gameMu.Unlock()
|
||||||
|
|
||||||
newSI := si.store.Create(width, height)
|
newSI := si.store.Create(width, height, mode, speed)
|
||||||
newID := newSI.ID()
|
newID := newSI.ID()
|
||||||
|
|
||||||
si.gameMu.Lock()
|
si.gameMu.Lock()
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ const (
|
|||||||
DirRight
|
DirRight
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type GameMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeMultiplayer GameMode = iota // Default (0) - backward compatible
|
||||||
|
ModeSinglePlayer // Single player survival mode
|
||||||
|
)
|
||||||
|
|
||||||
// Opposite returns true if a and b are 180-degree reversals.
|
// Opposite returns true if a and b are 180-degree reversals.
|
||||||
func (d Direction) Opposite(other Direction) bool {
|
func (d Direction) Opposite(other Direction) bool {
|
||||||
switch d {
|
switch d {
|
||||||
@@ -88,8 +95,26 @@ type SnakeGame struct {
|
|||||||
Winner *Player // nil if draw
|
Winner *Player // nil if draw
|
||||||
CountdownEnd time.Time // when countdown reaches 0
|
CountdownEnd time.Time // when countdown reaches 0
|
||||||
RematchGameID *string
|
RematchGameID *string
|
||||||
|
Mode GameMode // ModeMultiplayer or ModeSinglePlayer
|
||||||
|
Score int // tracks food eaten in single player
|
||||||
|
Speed int // cells per second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Speed presets
|
||||||
|
type SpeedPreset struct {
|
||||||
|
Name string
|
||||||
|
Speed int
|
||||||
|
}
|
||||||
|
|
||||||
|
var SpeedPresets = []SpeedPreset{
|
||||||
|
{Name: "Slow", Speed: 5},
|
||||||
|
{Name: "Normal", Speed: 7},
|
||||||
|
{Name: "Fast", Speed: 10},
|
||||||
|
{Name: "Insane", Speed: 15},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultSpeed = 7
|
||||||
|
|
||||||
func (sg *SnakeGame) IsFinished() bool {
|
func (sg *SnakeGame) IsFinished() bool {
|
||||||
return sg.Status == StatusFinished
|
return sg.Status == StatusFinished
|
||||||
}
|
}
|
||||||
@@ -112,9 +137,11 @@ type GridPreset struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var GridPresets = []GridPreset{
|
var GridPresets = []GridPreset{
|
||||||
|
{Name: "Tiny", Width: 15, Height: 15},
|
||||||
{Name: "Small", Width: 20, Height: 20},
|
{Name: "Small", Width: 20, Height: 20},
|
||||||
{Name: "Medium", Width: 30, Height: 20},
|
{Name: "Medium", Width: 30, Height: 20},
|
||||||
{Name: "Large", Width: 40, Height: 20},
|
{Name: "Large", Width: 40, Height: 20},
|
||||||
|
{Name: "XL", Width: 50, Height: 30},
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshot returns a shallow copy of the game safe for reading outside the lock.
|
// snapshot returns a shallow copy of the game safe for reading outside the lock.
|
||||||
@@ -132,6 +159,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
|
|||||||
}
|
}
|
||||||
cp.Players = make([]*Player, len(sg.Players))
|
cp.Players = make([]*Player, len(sg.Players))
|
||||||
copy(cp.Players, sg.Players)
|
copy(cp.Players, sg.Players)
|
||||||
|
// Mode and Score are value types, already copied by *sg
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
54
ui/lobby.go
54
ui/lobby.go
@@ -6,20 +6,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LobbyProps struct {
|
type LobbyProps struct {
|
||||||
NicknameBind h.H
|
NicknameBind h.H
|
||||||
CreateGameKeyDown h.H
|
CreateGameKeyDown h.H
|
||||||
CreateGameClick h.H
|
CreateGameClick h.H
|
||||||
IsLoggedIn bool
|
IsLoggedIn bool
|
||||||
Username string
|
Username string
|
||||||
LogoutClick h.H
|
LogoutClick h.H
|
||||||
UserGames []GameListItem
|
UserGames []GameListItem
|
||||||
DeleteGameClick func(id string) h.H
|
DeleteGameClick func(id string) h.H
|
||||||
ActiveTab string
|
ActiveTab string
|
||||||
TabClickConnect4 h.H
|
TabClickConnect4 h.H
|
||||||
TabClickSnake h.H
|
TabClickSnake h.H
|
||||||
SnakeNicknameBind h.H
|
SnakeNicknameBind h.H
|
||||||
SnakePresetClicks []h.H
|
SnakeSoloClicks []h.H
|
||||||
ActiveSnakeGames []*snake.SnakeGame
|
SnakeMultiClicks []h.H
|
||||||
|
ActiveSnakeGames []*snake.SnakeGame
|
||||||
|
SelectedSpeedIndex int
|
||||||
|
SpeedSelectClicks []h.H
|
||||||
|
}
|
||||||
|
|
||||||
|
func BackToLobby() h.H {
|
||||||
|
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 {
|
||||||
@@ -40,17 +56,17 @@ func LobbyView(p LobbyProps) h.H {
|
|||||||
|
|
||||||
var tabContent h.H
|
var tabContent h.H
|
||||||
if p.ActiveTab == "snake" {
|
if p.ActiveTab == "snake" {
|
||||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames)
|
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames, p.SelectedSpeedIndex, p.SpeedSelectClicks)
|
||||||
} else {
|
} else {
|
||||||
tabContent = connect4LobbyContent(p)
|
tabContent = connect4LobbyContent(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -58,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")),
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cell size scales with grid dimensions
|
// Cell size scales with grid dimensions
|
||||||
cellSize := 20
|
cellSize := cellSizeForGrid(state.Width, state.Height)
|
||||||
if state.Width <= 20 {
|
|
||||||
cellSize = 24
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []h.H
|
var rows []h.H
|
||||||
for y := 0; y < state.Height; y++ {
|
for y := 0; y < state.Height; y++ {
|
||||||
@@ -94,3 +91,22 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
|
|||||||
attrs = append(attrs, rows...)
|
attrs = append(attrs, rows...)
|
||||||
return h.Div(attrs...)
|
return h.Div(attrs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cellSizeForGrid(width, height int) int {
|
||||||
|
maxDim := width
|
||||||
|
if height > maxDim {
|
||||||
|
maxDim = height
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case maxDim <= 15:
|
||||||
|
return 28
|
||||||
|
case maxDim <= 20:
|
||||||
|
return 24
|
||||||
|
case maxDim <= 30:
|
||||||
|
return 20
|
||||||
|
case maxDim <= 40:
|
||||||
|
return 16
|
||||||
|
default:
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,14 +7,32 @@ import (
|
|||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H {
|
func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame, selectedSpeedIndex int, speedSelectClicks []h.H) h.H {
|
||||||
var presetButtons []h.H
|
// Solo play buttons
|
||||||
|
var soloButtons []h.H
|
||||||
for i, preset := range snake.GridPresets {
|
for i, preset := range snake.GridPresets {
|
||||||
var click h.H
|
var click h.H
|
||||||
if i < len(presetClicks) {
|
if i < len(soloClicks) {
|
||||||
click = presetClicks[i]
|
click = soloClicks[i]
|
||||||
}
|
}
|
||||||
presetButtons = append(presetButtons,
|
soloButtons = append(soloButtons,
|
||||||
|
h.Button(
|
||||||
|
h.Class("btn btn-secondary"),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||||
|
click,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplayer buttons
|
||||||
|
var multiButtons []h.H
|
||||||
|
for i, preset := range snake.GridPresets {
|
||||||
|
var click h.H
|
||||||
|
if i < len(multiClicks) {
|
||||||
|
click = multiClicks[i]
|
||||||
|
}
|
||||||
|
multiButtons = append(multiButtons,
|
||||||
h.Button(
|
h.Button(
|
||||||
h.Class("btn btn-primary"),
|
h.Class("btn btn-primary"),
|
||||||
h.Type("button"),
|
h.Type("button"),
|
||||||
@@ -24,22 +42,51 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createSection := h.Div(h.Class("mb-6"),
|
nicknameField := h.Div(h.Class("mb-4"),
|
||||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Game")),
|
h.FieldSet(h.Class("fieldset"),
|
||||||
h.Div(h.Class("mb-4"),
|
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||||
h.FieldSet(h.Class("fieldset"),
|
h.Input(
|
||||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
h.Class("input input-bordered w-full"),
|
||||||
h.Input(
|
h.ID("snake-nickname"),
|
||||||
h.Class("input input-bordered w-full"),
|
h.Type("text"),
|
||||||
h.ID("snake-nickname"),
|
h.Placeholder("Enter your nickname"),
|
||||||
h.Type("text"),
|
nicknameBind,
|
||||||
h.Placeholder("Enter your nickname"),
|
h.Attr("required"),
|
||||||
nicknameBind,
|
|
||||||
h.Attr("required"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
|
)
|
||||||
|
|
||||||
|
// Speed selector
|
||||||
|
var speedButtons []h.H
|
||||||
|
for i, preset := range snake.SpeedPresets {
|
||||||
|
btnClass := "btn btn-sm"
|
||||||
|
if i == selectedSpeedIndex {
|
||||||
|
btnClass += " btn-active"
|
||||||
|
}
|
||||||
|
var click h.H
|
||||||
|
if i < len(speedSelectClicks) {
|
||||||
|
click = speedSelectClicks[i]
|
||||||
|
}
|
||||||
|
speedButtons = append(speedButtons, h.Button(
|
||||||
|
h.Class(btnClass),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text(preset.Name),
|
||||||
|
click,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
speedSelector := h.Div(h.Class("mb-4"),
|
||||||
|
h.Label(h.Class("label"), h.Text("Speed")),
|
||||||
|
h.Div(append([]h.H{h.Class("btn-group")}, speedButtons...)...),
|
||||||
|
)
|
||||||
|
|
||||||
|
soloSection := h.Div(h.Class("mb-6"),
|
||||||
|
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")),
|
||||||
|
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...),
|
||||||
|
)
|
||||||
|
|
||||||
|
multiSection := h.Div(h.Class("mb-6"),
|
||||||
|
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Multiplayer Game")),
|
||||||
|
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, multiButtons...)...),
|
||||||
)
|
)
|
||||||
|
|
||||||
var gameListEl h.H
|
var gameListEl h.H
|
||||||
@@ -68,7 +115,10 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return h.Div(
|
return h.Div(
|
||||||
createSection,
|
nicknameField,
|
||||||
|
speedSelector,
|
||||||
|
soloSection,
|
||||||
|
multiSection,
|
||||||
gameListEl,
|
gameListEl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import (
|
|||||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||||
switch sg.Status {
|
switch sg.Status {
|
||||||
case snake.StatusWaitingForPlayers:
|
case snake.StatusWaitingForPlayers:
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||||
|
h.Text("Ready?"),
|
||||||
|
)
|
||||||
|
}
|
||||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||||
h.Text("Waiting for players..."),
|
h.Text("Waiting for players..."),
|
||||||
)
|
)
|
||||||
@@ -35,6 +40,12 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Show score during single player gameplay
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||||
|
h.Text(fmt.Sprintf("Score: %d", sg.Score)),
|
||||||
|
)
|
||||||
|
}
|
||||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||||
h.Text("Go!"),
|
h.Text("Go!"),
|
||||||
)
|
)
|
||||||
@@ -42,7 +53,11 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
|||||||
case snake.StatusFinished:
|
case snake.StatusFinished:
|
||||||
var msg string
|
var msg string
|
||||||
var class string
|
var class string
|
||||||
if sg.Winner != nil {
|
|
||||||
|
if sg.Mode == snake.ModeSinglePlayer {
|
||||||
|
msg = fmt.Sprintf("Game Over! Score: %d", sg.Score)
|
||||||
|
class = "alert alert-info text-xl font-bold"
|
||||||
|
} else if sg.Winner != nil {
|
||||||
if sg.Winner.Slot == mySlot {
|
if sg.Winner.Slot == mySlot {
|
||||||
msg = "You win!"
|
msg = "You win!"
|
||||||
class = "alert alert-success text-xl font-bold"
|
class = "alert alert-success text-xl font-bold"
|
||||||
@@ -130,7 +145,7 @@ func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SnakeInviteLink(gameID string) h.H {
|
func SnakeInviteLink(gameID string) h.H {
|
||||||
fullURL := baseURL + "/snake/" + gameID
|
fullURL := getBaseURL() + "/snake/" + gameID
|
||||||
return h.Div(h.Class("mt-4 text-center"),
|
return h.Div(h.Class("mt-4 text-center"),
|
||||||
h.P(h.Text("Share this link to invite players:")),
|
h.P(h.Text("Share this link to invite players:")),
|
||||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||||
|
|||||||
11
ui/status.go
11
ui/status.go
@@ -1,6 +1,8 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/c4/game"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
@@ -115,10 +117,15 @@ func PlayerInfo(g *game.Game, myColor int) h.H {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseURL = "https://demo.adriatica.io"
|
func getBaseURL() string {
|
||||||
|
if url := os.Getenv("APP_URL"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return "https://games.adriatica.io"
|
||||||
|
}
|
||||||
|
|
||||||
func InviteLink(gameID string) h.H {
|
func InviteLink(gameID string) h.H {
|
||||||
fullURL := baseURL + "/game/" + gameID
|
fullURL := getBaseURL() + "/game/" + gameID
|
||||||
return h.Div(h.Class("mt-4 text-center"),
|
return h.Div(h.Class("mt-4 text-center"),
|
||||||
h.P(h.Text("Share this link with your opponent:")),
|
h.P(h.Text("Share this link with your opponent:")),
|
||||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||||
|
|||||||
Reference in New Issue
Block a user