29 Commits

Author SHA1 Message Date
Ryan Hamamura
e68e4b48f5 fix: resolve nil pubsub preventing live game updates
All checks were successful
Deploy c4 / deploy (push) Successful in 45s
v.PubSub() was captured at startup before v.Start() initialized NATS,
so both stores held nil and notify() silently no-oped. Replace the
PubSub interface with a callback that evaluates v.PubSub() lazily at
call time.
2026-02-20 12:37:28 -10:00
Ryan Hamamura
91b5f2b80c deps: update ryanhamamura/via to v0.23.0
All checks were successful
Deploy c4 / deploy (push) Successful in 57s
Remove ContextSuspendAfter and ContextTTL options, which were
deleted upstream. Contexts now persist until SSE close beacon
or server shutdown.
2026-02-20 12:06:51 -10:00
Ryan Hamamura
73b6e3bcc5 deps: update ryanhamamura/via to v0.21.2
All checks were successful
Deploy c4 / deploy (push) Successful in 1m0s
2026-02-20 09:26:24 -10:00
Ryan Hamamura
ffd44ae56b deps: update ryanhamamura/via to v0.19.0
All checks were successful
Deploy c4 / deploy (push) Successful in 54s
2026-02-19 12:13:00 -10:00
Ryan Hamamura
5a5cd08abb fix: use correct runner label in deploy workflow
All checks were successful
Deploy c4 / deploy (push) Successful in 59s
The workflow was hanging because `ubuntu-latest` doesn't match the
Gitea Actions runner registered with the `games` label.
2026-02-19 11:20:42 -10:00
Ryan Hamamura
884650c68d feat: integrate via v0.18.1 context suspension and key throttling
Some checks failed
Deploy c4 / deploy (push) Has been cancelled
Upgrade via to v0.18.1 and configure context suspension timeouts
(5min suspend, 30min TTL) for clean reconnection behavior. Throttle
snake direction key inputs to 100ms to prevent wasted SSE round-trips
when keys are held down.
2026-02-19 11:14:35 -10:00
Ryan Hamamura
c5b863efdd chore: trigger CI/CD
Some checks failed
Deploy c4 / deploy (push) Has been cancelled
2026-02-19 09:38:00 -10:00
Ryan Hamamura
968c2cdb61 feat: add glowing effect to active player's pieces
All checks were successful
Deploy c4 / deploy (push) Successful in 42s
Pulsing box-shadow on the current turn's placed pieces makes the
turn state visible directly on the board rather than only in the
status banner.
2026-02-13 12:34:19 -10:00
Ryan Hamamura
c541ba56d4 fix: responsive C4 board on mobile and preserve chat input during morph
All checks were successful
Deploy c4 / deploy (push) Successful in 42s
Shrink board cells to 36px with tighter gaps on <768px screens so the
board fits on 375px phones. Add DataIgnoreMorph to the C4 chat input
so typing isn't disrupted when new messages arrive.
2026-02-13 12:26:24 -10:00
3593197271 feat: persist chat messages to SQLite (#1)
All checks were successful
Deploy c4 / deploy (push) Successful in 44s
2026-02-13 22:00:01 +00:00
Ryan Hamamura
08c20a1732 chore: re-upgrade via to v0.15.1
All checks were successful
Deploy c4 / deploy (push) Successful in 43s
The login bug was caused by missing RenewToken(), not the via update.
2026-02-13 11:43:31 -10:00
Ryan Hamamura
deff9b3859 fix: renew session token after login/register to persist session data
All checks were successful
Deploy c4 / deploy (push) Successful in 42s
Without RenewToken(), session data set during the action handler
wasn't surviving the redirect — the old pre-auth token was stale.
2026-02-13 11:35:37 -10:00
Ryan Hamamura
645d958041 revert: downgrade via to v0.15.0 to debug login regression
All checks were successful
Deploy c4 / deploy (push) Successful in 4s
Login on deployed server stopped persisting session data after the
v0.15.1 update. Reverting to isolate whether the via update caused it.
2026-02-13 11:29:17 -10:00
Ryan Hamamura
f238e126d3 chore: update via to v0.15.1
All checks were successful
Deploy c4 / deploy (push) Successful in 54s
2026-02-13 10:59:00 -10:00
Ryan Hamamura
9069530e47 feat: add in-game chat to Connect 4
All checks were successful
Deploy c4 / deploy (push) Successful in 43s
Add real-time chat alongside the game board, mirroring the snake chat
implementation. Fix mobile layout for both C4 and snake chats — expand
chat to full width and reduce history height on small screens.
2026-02-13 10:54:19 -10:00
Ryan Hamamura
e45559ecb3 chore: update APP_URL to games.adriatica.io
All checks were successful
Deploy c4 / deploy (push) Successful in 39s
2026-02-13 09:24:49 -10:00
Ryan Hamamura
e85271ab29 feat: stealth mode — replace game-related text with discrete symbols
All checks were successful
Deploy c4 / deploy (push) Successful in 40s
Replace "Game Lobby", "Connect 4", and "Snake" headings with colored
circles (●●●●) and tildes (~~~~) so the UI is less obviously a game
at a glance. Also neutralize the lobby description text and shorten
the back link.
2026-02-13 09:22:07 -10:00
Ryan Hamamura
9799387a32 fix: ensure data directory has correct ownership before starting container
All checks were successful
Deploy c4 / deploy (push) Successful in 41s
On a fresh VM, Docker creates the bind-mounted data/ directory as
root:root, but the container runs as games (UID 5). This causes
SQLite to fail with SQLITE_CANTOPEN. Create the directory with
correct ownership in the deploy pipeline.
2026-02-13 09:03:33 -10:00
Ryan Hamamura
dfc2111be5 feat: add CI/CD workflow and switch to bind mount for data
All checks were successful
Deploy c4 / deploy (push) Successful in 1m19s
Use a Gitea Actions workflow to deploy on push to main via
act_runner on the games VM. Switch from a Docker named volume
to a ./data bind mount for easier backup and persistence across
deploys.
2026-02-13 08:42:51 -10:00
Ryan Hamamura
427521505b feat: add Docker Compose deployment and serve assets via StaticFS
Embed full assets directory and serve with via's StaticFS instead of a
custom HTTP handler. Move SQLite DB to data/c4.db for clean volume
mounting. Add multi-stage Dockerfile, docker-compose.yml, and
.dockerignore.
2026-02-13 08:02:15 -10:00
Ryan Hamamura
b0449fbeb9 chore: rebuild CSS output 2026-02-12 15:10:38 -10:00
Ryan Hamamura
d2ed3cffd9 deps: update via to v0.15.0, remove vianats dependency
NATS is now built into via.New() automatically. Stores use the new
v.PubSub() accessor instead of the removed vianats package.
2026-02-12 15:10:18 -10:00
Ryan Hamamura
3d019fd948 feat: add snake multiplayer chat as sidebar with vivid player colors
Move chat to the right of the board using a flex wrapper that stacks
vertically on narrow screens. Restore original snake player colors by
removing the desaturation filter added with the dark theme.
2026-02-05 10:15:26 -10:00
Ryan Hamamura
0279615b36 feat: add back to lobby navigation on game pages 2026-02-05 09:55:43 -10:00
Ryan Hamamura
73128dc119 style: dark stealth theme with teal/burgundy pieces and cache-busting
Darken the DaisyUI theme and game board colors for a muted, low-chroma
aesthetic. Pieces use dark teal vs burgundy for subtle contrast.
Add MD5-based cache busting to the DaisyUI stylesheet link so CSS
changes are picked up without a hard refresh.
2026-02-05 09:51:36 -10:00
Ryan Hamamura
9a3d1fd164 refactor: reduce snake animation delays for snappier gameplay
Remove transitions and simplify head-pop animation to feel more
responsive at higher speed settings.
2026-02-04 10:04:27 -10:00
Ryan Hamamura
e239e948ae feat: add configurable speed and expanded grid presets for snake
- Add per-game speed setting with presets (Slow/Normal/Fast/Insane)
- Add speed selector UI in snake lobby
- Expand grid presets with Tiny (15x15) and XL (50x30)
- Auto-calculate cell size based on grid dimensions
- Preserve speed setting in rematch games
2026-02-04 10:02:40 -10:00
Ryan Hamamura
f454e0d220 feat: add single player snake mode
Add solo mode where players survive as long as possible while tracking
score (food eaten). Single player games start with a shorter 3-second
countdown vs 10 seconds for multiplayer, maintain exactly 1 food item
for classic snake feel, and end when the player dies rather than when
one player remains.

- Add GameMode type (ModeMultiplayer/ModeSinglePlayer) and Score field
- Filter single player games from "Join a Game" lobby list
- Show "Ready?" and "Score: X" UI for single player mode
- Hide invite link for single player games
- Preserve game mode on rematch
2026-02-04 07:33:02 -10:00
Ryan Hamamura
7faf94fa6d feat: make invite link base URL configurable via APP_URL
Load environment variables from .env file using godotenv.
Defaults to https://demo.adriatica.io if APP_URL is not set.
2026-02-04 07:02:52 -10:00
34 changed files with 3235 additions and 242 deletions

10
.dockerignore Normal file
View 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
View 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

View 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
View File

@@ -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
View 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"]

View File

@@ -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 & brightwarm, casual board game feel */ /* Muted stealthdark, 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
View 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
}

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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

View 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;

View 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;

View 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;

View File

@@ -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
View 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;

View File

@@ -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
View 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

View File

@@ -6,10 +6,6 @@ import (
"sync" "sync"
) )
type PubSub interface {
Publish(subject string, data []byte) error
}
type PlayerSession struct { type PlayerSession struct {
Player *Player Player *Player
} }
@@ -26,7 +22,7 @@ type GameStore struct {
games map[string]*GameInstance games map[string]*GameInstance
gamesMu sync.RWMutex gamesMu sync.RWMutex
persister Persister persister Persister
pubsub PubSub notifyFunc func(gameID string)
} }
func NewGameStore() *GameStore { func NewGameStore() *GameStore {
@@ -39,14 +35,14 @@ func (gs *GameStore) SetPersister(p Persister) {
gs.persister = p gs.persister = p
} }
func (gs *GameStore) SetPubSub(ps PubSub) { func (gs *GameStore) SetNotifyFunc(f func(gameID string)) {
gs.pubsub = ps gs.notifyFunc = f
} }
func (gs *GameStore) makeNotify(gameID string) func() { func (gs *GameStore) makeNotify(gameID string) func() {
return func() { return func() {
if gs.pubsub != nil { if gs.notifyFunc != nil {
gs.pubsub.Publish("game."+gameID, nil) gs.notifyFunc(gameID)
} }
} }
} }

5
go.mod
View File

@@ -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
View File

@@ -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=

245
main.go
View File

@@ -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,43 @@ 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.SetNotifyFunc(func(gameID string) {
v.PubSub().Publish("game."+gameID, nil)
})
snakeStore.SetNotifyFunc(func(gameID string) {
v.PubSub().Publish("snake."+gameID, nil)
})
// 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,17 +161,50 @@ 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())
} }
@@ -178,8 +223,11 @@ func main() {
TabClickConnect4: tabClickConnect4.OnClick(), TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(), TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(), SnakeNicknameBind: snakeNickname.Bind(),
SnakePresetClicks: snakePresetClicks, SnakeSoloClicks: snakeSoloClicks,
SnakeMultiClicks: snakeMultiClicks,
ActiveSnakeGames: snakeStore.ActiveGames(), ActiveSnakeGames: snakeStore.ActiveGames(),
SelectedSpeedIndex: selectedSpeedIndex.Int(),
SpeedSelectClicks: speedSelectClicks,
}) })
}) })
}) })
@@ -209,6 +257,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 +326,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 +361,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 +445,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 +537,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 +650,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 +736,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)),
), ),
} }

View File

@@ -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 {

View File

@@ -7,15 +7,19 @@ 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,17 +112,12 @@ 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]
si.pendingDir[i] = nil
inputReceived = true
}
}
if inputReceived {
lastInput = time.Now() lastInput = time.Now()
break
}
} }
// Inactivity timeout // Inactivity timeout
@@ -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,17 +166,34 @@ 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)
gameOver := false
if si.game.Mode == ModeSinglePlayer {
// Single player ends when the player dies (alive == 0)
if alive == 0 {
gameOver = true
// No winner in single player - just final score
}
} else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 { if alive <= 1 {
si.game.Status = StatusFinished gameOver = true
winnerIdx := LastAlive(state) winnerIdx := LastAlive(state)
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) { if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
si.game.Winner = si.game.Players[winnerIdx] si.game.Winner = si.game.Players[winnerIdx]
} }
} }
}
if gameOver {
si.game.Status = StatusFinished
}
if si.persister != nil { if si.persister != nil {
si.persister.SaveSnakeGame(si.game) si.persister.SaveSnakeGame(si.game)
@@ -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
} }
} }

View File

@@ -6,10 +6,6 @@ import (
"sync" "sync"
) )
type PubSub interface {
Publish(subject string, data []byte) error
}
type Persister interface { type Persister interface {
SaveSnakeGame(sg *SnakeGame) error SaveSnakeGame(sg *SnakeGame) error
LoadSnakeGame(id string) (*SnakeGame, error) LoadSnakeGame(id string) (*SnakeGame, error)
@@ -22,7 +18,7 @@ type SnakeStore struct {
games map[string]*SnakeGameInstance games map[string]*SnakeGameInstance
gamesMu sync.RWMutex gamesMu sync.RWMutex
persister Persister persister Persister
pubsub PubSub notifyFunc func(gameID string)
} }
func NewSnakeStore() *SnakeStore { func NewSnakeStore() *SnakeStore {
@@ -35,19 +31,22 @@ func (ss *SnakeStore) SetPersister(p Persister) {
ss.persister = p ss.persister = p
} }
func (ss *SnakeStore) SetPubSub(ps PubSub) { func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
ss.pubsub = ps ss.notifyFunc = f
} }
func (ss *SnakeStore) makeNotify(gameID string) func() { func (ss *SnakeStore) makeNotify(gameID string) func() {
return func() { return func() {
if ss.pubsub != nil { if ss.notifyFunc != nil {
ss.pubsub.Publish("snake."+gameID, nil) ss.notifyFunc(gameID)
} }
} }
} }
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 +56,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 +135,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 +149,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()
@@ -159,7 +160,7 @@ 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
@@ -220,15 +221,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 +240,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]
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
return return
} }
s := si.game.State.Snakes[slot]
if s == nil || !s.Alive {
return
} }
si.pendingDir[slot] = &dir
// 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 +286,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()

View File

@@ -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
} }

View File

@@ -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
View 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),
),
)
}

View File

@@ -18,8 +18,24 @@ type LobbyProps struct {
TabClickConnect4 h.H TabClickConnect4 h.H
TabClickSnake h.H TabClickSnake h.H
SnakeNicknameBind h.H SnakeNicknameBind h.H
SnakePresetClicks []h.H SnakeSoloClicks []h.H
SnakeMultiClicks []h.H
ActiveSnakeGames []*snake.SnakeGame 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")),

View File

@@ -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
View 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),
),
)
}

View File

@@ -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,9 +42,7 @@ 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.Div(h.Class("mb-4"),
h.FieldSet(h.Class("fieldset"), h.FieldSet(h.Class("fieldset"),
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")), h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
h.Input( h.Input(
@@ -38,8 +54,39 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
h.Attr("required"), 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,
) )
} }

View File

@@ -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"),

View File

@@ -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"),