WIP: Add multiplayer Snake game
N-player (2-8) real-time Snake game alongside Connect 4. Lobby has tabs to switch between games. Players join via invite link with 10-second countdown. Game loop runs at tick-based intervals with NATS pub/sub for state sync. Keyboard input not yet working (Datastar keydown binding issue still under investigation).
This commit is contained in:
@@ -61,3 +61,27 @@
|
||||
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; }
|
||||
.player-chip.red { background: #dc2626; }
|
||||
.player-chip.yellow { background: #facc15; }
|
||||
|
||||
/* Snake game */
|
||||
.snake-board {
|
||||
display: inline-grid;
|
||||
gap: 0;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 3px solid #333;
|
||||
}
|
||||
.snake-row { display: contents; }
|
||||
.snake-cell {
|
||||
background: #16213e;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
transition: background 0.05s;
|
||||
}
|
||||
.snake-cell.snake-food {
|
||||
background: #ff6b6b;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px rgba(255,107,107,0.6);
|
||||
}
|
||||
.snake-cell.snake-head { border-radius: 4px; }
|
||||
.snake-cell.snake-dead { opacity: 0.35; }
|
||||
.snake-wrapper:focus { outline: none; }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--container-sm: 24rem;
|
||||
--container-md: 28rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
@@ -179,6 +180,93 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.tab {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
}
|
||||
--tab-p: 0.75rem;
|
||||
--tab-bg: var(--color-base-100);
|
||||
--tab-border-color: var(--color-base-300);
|
||||
--tab-radius-ss: 0;
|
||||
--tab-radius-se: 0;
|
||||
--tab-radius-es: 0;
|
||||
--tab-radius-ee: 0;
|
||||
--tab-order: 0;
|
||||
--tab-radius-min: calc(0.75rem - var(--border));
|
||||
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
|
||||
--tab-radius-grad: #0000 calc(69% - var(--border)),
|
||||
var(--tab-border-color) calc(calc(69% - var(--border)) + 0.25px),
|
||||
var(--tab-border-color) calc(calc(69% - var(--border)) + var(--border)),
|
||||
var(--tab-bg) calc(calc(69% - var(--border)) + var(--border) + 0.25px);
|
||||
border-color: #0000;
|
||||
order: var(--tab-order);
|
||||
height: var(--tab-height);
|
||||
font-size: 0.875rem;
|
||||
padding-inline-start: var(--tab-p);
|
||||
padding-inline-end: var(--tab-p);
|
||||
&:is(input[type="radio"]) {
|
||||
min-width: fit-content;
|
||||
&:after {
|
||||
--tw-content: attr(aria-label);
|
||||
content: var(--tw-content);
|
||||
}
|
||||
}
|
||||
&:is(label) {
|
||||
position: relative;
|
||||
input {
|
||||
position: absolute;
|
||||
inset: calc(0.25rem * 0);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
|
||||
& + .tab-content {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
}
|
||||
}
|
||||
&:not(input):empty {
|
||||
flex-grow: 1;
|
||||
cursor: default;
|
||||
}
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: -5px;
|
||||
}
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
:where(&) {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
@@ -311,6 +399,58 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.countdown {
|
||||
&.countdown {
|
||||
line-height: 1em;
|
||||
}
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-flex;
|
||||
& > * {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow-y: clip;
|
||||
transition: width 0.4s ease-out 0.2s;
|
||||
height: 1em;
|
||||
--value-v: calc(mod(max(0, var(--value)), 1000));
|
||||
--value-hundreds: calc(round(to-zero, var(--value-v) / 100, 1));
|
||||
--value-tens: calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));
|
||||
--value-ones: calc(mod(var(--value-v), 100));
|
||||
--show-hundreds: clamp(clamp(0, var(--digits, 1) - 2, 1), var(--value-hundreds), 1);
|
||||
--show-tens: clamp(
|
||||
clamp(0, var(--digits, 1) - 1, 1),
|
||||
var(--value-tens) + var(--show-hundreds),
|
||||
1
|
||||
);
|
||||
--first-digits: calc(round(to-zero, var(--value-v) / 10, 1));
|
||||
width: calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);
|
||||
direction: ltr;
|
||||
&:before, &:after {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
overflow-x: clip;
|
||||
--tw-content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";
|
||||
content: var(--tw-content);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: pre;
|
||||
text-align: end;
|
||||
direction: rtl;
|
||||
transition: all 1s cubic-bezier(1, 0, 0, 1), width 0.2s ease-out 0.2s, opacity 0.2s ease-out 0.2s;
|
||||
}
|
||||
&:before {
|
||||
width: calc(1ch + var(--show-hundreds) * 1ch);
|
||||
top: calc(var(--first-digits) * -1em);
|
||||
inset-inline-end: 0;
|
||||
opacity: var(--show-tens);
|
||||
}
|
||||
&:after {
|
||||
width: 1ch;
|
||||
top: calc(var(--value-ones) * -1em);
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.input {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
cursor: text;
|
||||
@@ -797,12 +937,46 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabs-box {
|
||||
@layer daisyui.l1.l2 {
|
||||
background-color: var(--color-base-200);
|
||||
padding: calc(0.25rem * 1);
|
||||
--tabs-box-radius: calc(3 * var(--radius-field));
|
||||
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) );
|
||||
box-shadow: 0 -0.5px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0.5px oklch(0% 0 0 / calc(var(--depth) * 0.05)) inset;
|
||||
> .tab {
|
||||
border-radius: var(--radius-field);
|
||||
border-style: none;
|
||||
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
> :is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]):not( .tab-disabled, [disabled] ), > :is(input:checked), > :is(label:has(:checked)) {
|
||||
background-color: var(--tab-bg, var(--color-base-100));
|
||||
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
> .tab-content {
|
||||
margin-top: calc(0.25rem * 1);
|
||||
height: calc(100% - var(--tab-height) + var(--border) - 0.5rem);
|
||||
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) - var(--border) );
|
||||
}
|
||||
}
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -812,6 +986,9 @@
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.ml-4 {
|
||||
margin-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -840,6 +1017,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabs {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
--tabs-height: auto;
|
||||
--tabs-direction: row;
|
||||
--tab-height: calc(var(--size-field, 0.25rem) * 10);
|
||||
height: var(--tabs-height);
|
||||
flex-direction: var(--tabs-direction);
|
||||
}
|
||||
}
|
||||
.alert {
|
||||
border-width: var(--border);
|
||||
border-color: var(--alert-border-color, var(--color-base-200));
|
||||
@@ -947,6 +1135,9 @@
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.btn-square {
|
||||
@layer daisyui.l1.l2 {
|
||||
padding-inline: calc(0.25rem * 0);
|
||||
@@ -957,6 +1148,9 @@
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: var(--container-sm);
|
||||
}
|
||||
@@ -984,6 +1178,9 @@
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1021,6 +1218,9 @@
|
||||
.p-2 {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
.p-3 {
|
||||
padding: calc(var(--spacing) * 3);
|
||||
}
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -1073,6 +1273,13 @@
|
||||
--alert-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
.alert-info {
|
||||
@layer daisyui.l1.l2 {
|
||||
color: var(--color-info-content);
|
||||
--alert-border-color: var(--color-info);
|
||||
--alert-color: var(--color-info);
|
||||
}
|
||||
}
|
||||
.alert-success {
|
||||
@layer daisyui.l1.l2 {
|
||||
color: var(--color-success-content);
|
||||
@@ -1099,6 +1306,9 @@
|
||||
.no-underline {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 40%;
|
||||
}
|
||||
.opacity-60 {
|
||||
opacity: 60%;
|
||||
}
|
||||
@@ -1226,6 +1436,36 @@
|
||||
.player-chip.yellow {
|
||||
background: #facc15;
|
||||
}
|
||||
.snake-board {
|
||||
display: inline-grid;
|
||||
gap: 0;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 3px solid #333;
|
||||
}
|
||||
.snake-row {
|
||||
display: contents;
|
||||
}
|
||||
.snake-cell {
|
||||
background: #16213e;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
transition: background 0.05s;
|
||||
}
|
||||
.snake-cell.snake-food {
|
||||
background: #ff6b6b;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px rgba(255,107,107,0.6);
|
||||
}
|
||||
.snake-cell.snake-head {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.snake-cell.snake-dead {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.snake-wrapper:focus {
|
||||
outline: none;
|
||||
}
|
||||
@layer base {
|
||||
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
|
||||
color-scheme: light;
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
const createGame = `-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id
|
||||
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
|
||||
`
|
||||
|
||||
type CreateGameParams struct {
|
||||
@@ -41,6 +41,10 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -81,7 +85,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
const getActiveGames = `-- name: GetActiveGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id FROM games WHERE 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 FROM games WHERE game_type = 'connect4' AND status < 2
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
@@ -103,6 +107,10 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,7 +126,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
}
|
||||
|
||||
const getGame = `-- name: GetGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id 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 FROM games WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
@@ -134,6 +142,10 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -174,7 +186,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
||||
}
|
||||
|
||||
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 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 FROM games g
|
||||
JOIN game_players gp ON g.id = gp.game_id
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
@@ -199,6 +211,10 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,7 +240,7 @@ SELECT
|
||||
FROM games g
|
||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
|
||||
WHERE g.status < 2
|
||||
WHERE g.game_type = 'connect4' AND g.status < 2
|
||||
ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ type Game struct {
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
RematchGameID sql.NullString
|
||||
GameType string
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
MaxPlayers int64
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
|
||||
263
db/gen/snake_games.sql.go
Normal file
263
db/gen/snake_games.sql.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: snake_games.sql
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createSnakeGame = `-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
||||
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
|
||||
`
|
||||
|
||||
type CreateSnakeGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createSnakeGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.GridWidth,
|
||||
arg.GridHeight,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSnakePlayer = `-- name: CreateSnakePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateSnakePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createSnakePlayer,
|
||||
arg.GameID,
|
||||
arg.UserID,
|
||||
arg.GuestPlayerID,
|
||||
arg.Nickname,
|
||||
arg.Color,
|
||||
arg.Slot,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSnakeGame = `-- name: DeleteSnakeGame :exec
|
||||
DELETE FROM games WHERE id = ? AND game_type = 'snake'
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteSnakeGame, id)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
); 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
|
||||
}
|
||||
|
||||
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'
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSnakeGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSnakePlayers = `-- name: GetSnakePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.UserID,
|
||||
&i.GuestPlayerID,
|
||||
&i.Nickname,
|
||||
&i.Color,
|
||||
&i.Slot,
|
||||
&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
|
||||
}
|
||||
|
||||
const getUserActiveSnakeGames = `-- name: GetUserActiveSnakeGames :many
|
||||
SELECT
|
||||
g.id,
|
||||
g.status,
|
||||
g.grid_width,
|
||||
g.grid_height,
|
||||
g.updated_at
|
||||
FROM games g
|
||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||
WHERE g.game_type = 'snake' AND g.status < 3
|
||||
ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
type GetUserActiveSnakeGamesRow struct {
|
||||
ID string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveSnakeGamesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserActiveSnakeGamesRow
|
||||
for rows.Next() {
|
||||
var i GetUserActiveSnakeGamesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Status,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.UpdatedAt,
|
||||
); 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
|
||||
}
|
||||
|
||||
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake'
|
||||
`
|
||||
|
||||
type UpdateSnakeGameParams struct {
|
||||
Board string
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateSnakeGame,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.WinnerUserID,
|
||||
arg.RematchGameID,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
13
db/migrations/003_add_snake.sql
Normal file
13
db/migrations/003_add_snake.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE games ADD COLUMN game_type TEXT NOT NULL DEFAULT 'connect4';
|
||||
ALTER TABLE games ADD COLUMN grid_width INTEGER;
|
||||
ALTER TABLE games ADD COLUMN grid_height INTEGER;
|
||||
ALTER TABLE games ADD COLUMN max_players INTEGER NOT NULL DEFAULT 2;
|
||||
CREATE INDEX idx_games_game_type ON games(game_type);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_games_game_type;
|
||||
ALTER TABLE games DROP COLUMN max_players;
|
||||
ALTER TABLE games DROP COLUMN grid_height;
|
||||
ALTER TABLE games DROP COLUMN grid_width;
|
||||
ALTER TABLE games DROP COLUMN game_type;
|
||||
142
db/persister.go
142
db/persister.go
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
@@ -138,3 +139,144 @@ func (p *GamePersister) DeleteGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteGame(ctx, id)
|
||||
}
|
||||
|
||||
// SnakePersister implements snake.Persister
|
||||
type SnakePersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewSnakePersister(q *gen.Queries) *SnakePersister {
|
||||
return &SnakePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
||||
ctx := context.Background()
|
||||
|
||||
boardJSON := "{}"
|
||||
if sg.State != nil {
|
||||
boardJSON = sg.State.ToJSON()
|
||||
}
|
||||
|
||||
var gridWidth, gridHeight sql.NullInt64
|
||||
if sg.State != nil {
|
||||
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
|
||||
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
|
||||
}
|
||||
|
||||
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateSnakeGame(ctx, gen.CreateSnakeGameParams{
|
||||
ID: sg.ID,
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if sg.Winner != nil && sg.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if sg.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateSnakeGame(ctx, gen.UpdateSnakeGameParams{
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
RematchGameID: rematchGameID,
|
||||
ID: sg.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetSnakeGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := snake.GameStateFromJSON(row.Board)
|
||||
if err != nil {
|
||||
state = &snake.GameState{}
|
||||
}
|
||||
if row.GridWidth.Valid {
|
||||
state.Width = int(row.GridWidth.Int64)
|
||||
}
|
||||
if row.GridHeight.Valid {
|
||||
state.Height = int(row.GridHeight.Int64)
|
||||
}
|
||||
|
||||
sg := &snake.SnakeGame{
|
||||
ID: row.ID,
|
||||
State: state,
|
||||
Players: make([]*snake.Player, 8),
|
||||
Status: snake.Status(row.Status),
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
sg.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateSnakePlayer(ctx, gen.CreateSnakePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Slot + 1),
|
||||
Slot: int64(player.Slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakePlayers(gameID string) ([]*snake.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetSnakePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*snake.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &snake.Player{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Slot),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = snake.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = snake.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) DeleteSnakeGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteSnakeGame(ctx, id)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ WHERE id = ?;
|
||||
DELETE FROM games WHERE id = ?;
|
||||
|
||||
-- name: GetActiveGames :many
|
||||
SELECT * FROM games WHERE status < 2;
|
||||
SELECT * FROM games WHERE game_type = 'connect4' AND status < 2;
|
||||
|
||||
-- name: CreateGamePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
@@ -41,5 +41,5 @@ SELECT
|
||||
FROM games g
|
||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
|
||||
WHERE g.status < 2
|
||||
WHERE g.game_type = 'connect4' AND g.status < 2
|
||||
ORDER BY g.updated_at DESC;
|
||||
|
||||
37
db/queries/snake_games.sql
Normal file
37
db/queries/snake_games.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSnakeGame :one
|
||||
SELECT * FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: DeleteSnakeGame :exec
|
||||
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: GetActiveSnakeGames :many
|
||||
SELECT * FROM games WHERE game_type = 'snake' AND status < 2;
|
||||
|
||||
-- name: CreateSnakePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetSnakePlayers :many
|
||||
SELECT * FROM game_players WHERE game_id = ? ORDER BY slot;
|
||||
|
||||
-- name: GetUserActiveSnakeGames :many
|
||||
SELECT
|
||||
g.id,
|
||||
g.status,
|
||||
g.grid_width,
|
||||
g.grid_height,
|
||||
g.updated_at
|
||||
FROM games g
|
||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
||||
WHERE g.game_type = 'snake' AND g.status < 3
|
||||
ORDER BY g.updated_at DESC;
|
||||
296
main.go
296
main.go
@@ -1,17 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
g "maragu.dev/gomponents"
|
||||
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
@@ -19,11 +27,43 @@ import (
|
||||
)
|
||||
|
||||
var store = game.NewGameStore()
|
||||
var snakeStore = snake.NewSnakeStore()
|
||||
var queries *gen.Queries
|
||||
|
||||
//go:embed assets/css/output.css
|
||||
var daisyUICSS []byte
|
||||
|
||||
// dataExpr renders an h.H attribute node and extracts the raw Datastar expression.
|
||||
// h.Data/h.Attr HTML-escape values, so we unescape to get the original expression.
|
||||
func dataExpr(node h.H) string {
|
||||
var buf bytes.Buffer
|
||||
node.Render(&buf)
|
||||
s := buf.String()
|
||||
start := strings.Index(s, `="`) + 2
|
||||
end := strings.LastIndex(s, `"`)
|
||||
if start < 2 || end <= start {
|
||||
return ""
|
||||
}
|
||||
return html.UnescapeString(s[start:end])
|
||||
}
|
||||
|
||||
// rawDataAttr outputs an unescaped data attribute. Needed because gomponents
|
||||
// HTML-escapes attribute values, which double-escapes expressions extracted
|
||||
// from rendered nodes. Implements gomponents' attribute interface so it
|
||||
// renders in the element's opening tag rather than as a child.
|
||||
type rawDataAttr struct {
|
||||
name, value string
|
||||
}
|
||||
|
||||
func (a rawDataAttr) Render(w io.Writer) error {
|
||||
_, err := fmt.Fprintf(w, ` %s="%s"`, a.name, a.value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a rawDataAttr) Type() g.NodeType {
|
||||
return g.AttributeType
|
||||
}
|
||||
|
||||
func DaisyUIPlugin(v *via.V) {
|
||||
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
@@ -38,6 +78,7 @@ func main() {
|
||||
}
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||
|
||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||
if err != nil {
|
||||
@@ -50,18 +91,19 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
store.SetPubSub(ns)
|
||||
snakeStore.SetPubSub(ns)
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DocumentTitle: "Connect 4",
|
||||
DocumentTitle: "Game Lobby",
|
||||
ServerAddress: ":7331",
|
||||
SessionManager: sessionManager,
|
||||
PubSub: ns,
|
||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||
})
|
||||
|
||||
// Home page - enter nickname and create game
|
||||
// Home page - tabbed lobby
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
@@ -89,6 +131,7 @@ func main() {
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
activeTab := c.Signal("connect4")
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
@@ -118,17 +161,53 @@ func main() {
|
||||
}).OnClick()
|
||||
}
|
||||
|
||||
tabClickConnect4 := c.Action(func() {
|
||||
activeTab.SetValue("connect4")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
tabClickSnake := c.Action(func() {
|
||||
activeTab.SetValue("snake")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
snakeNickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
snakeNickname = c.Signal(username)
|
||||
}
|
||||
|
||||
// Snake create game actions — one per preset
|
||||
var snakePresetClicks []h.H
|
||||
for _, preset := range snake.GridPresets {
|
||||
w, ht := preset.Width, preset.Height
|
||||
snakePresetClicks = append(snakePresetClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
si := snakeStore.Create(w, ht)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LobbyView(
|
||||
nickname.Bind(),
|
||||
createGame.OnKeyDown("Enter"),
|
||||
createGame.OnClick(),
|
||||
isLoggedIn,
|
||||
username,
|
||||
logout.OnClick(),
|
||||
userGames,
|
||||
deleteGame,
|
||||
)
|
||||
return ui.LobbyView(ui.LobbyProps{
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakePresetClicks: snakePresetClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +329,7 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Game page
|
||||
// Connect 4 game page
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
@@ -278,19 +357,16 @@ func main() {
|
||||
var gi *game.GameInstance
|
||||
var gameExists bool
|
||||
|
||||
// Look up game (may not exist during warmup or invalid ID)
|
||||
if gameID != "" {
|
||||
gi, gameExists = store.Get(gameID)
|
||||
}
|
||||
|
||||
// Generate a stable player ID for this session
|
||||
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
playerID = game.PlayerID(game.GenerateID(8))
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
// Use user_id as player_id if logged in
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
@@ -305,7 +381,6 @@ func main() {
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
// Try to join if not already in game
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
@@ -344,12 +419,10 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to game updates so the opponent's moves trigger a re-render
|
||||
if gameExists {
|
||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
||||
}
|
||||
|
||||
// If nickname exists in session and game exists, join immediately
|
||||
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
@@ -364,7 +437,6 @@ func main() {
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
// Game not found - redirect to home
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
@@ -372,9 +444,7 @@ func main() {
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
// Need nickname first / not joined yet
|
||||
if myColor == 0 {
|
||||
// Unauthenticated user who hasn't chosen to continue as guest
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
@@ -391,7 +461,6 @@ func main() {
|
||||
|
||||
g := gi.GetGame()
|
||||
|
||||
// Create column click function
|
||||
columnClick := func(col int) h.H {
|
||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||
}
|
||||
@@ -404,7 +473,6 @@ func main() {
|
||||
ui.BoardComponent(g, columnClick, myColor),
|
||||
)
|
||||
|
||||
// Show invite link when waiting for opponent
|
||||
if g.Status == game.StatusWaitingForPlayer {
|
||||
content = append(content, ui.InviteLink(g.ID))
|
||||
}
|
||||
@@ -415,5 +483,185 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Snake game page
|
||||
v.Page("/snake/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var si *snake.SnakeGameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
si, gameExists = snakeStore.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := snake.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
pid := game.GenerateID(8)
|
||||
playerID = snake.PlayerID(pid)
|
||||
c.Session().Set("player_id", pid)
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
playerID = snake.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// Direction input: single action with a direction signal
|
||||
dirSignal := c.Signal(-1)
|
||||
handleDir := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
dir := snake.Direction(dirSignal.Int())
|
||||
si.SetDirection(slot, dir)
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
newSI := si.CreateRematch()
|
||||
if newSI != nil {
|
||||
c.Redirectf("/snake/%s", newSI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
|
||||
}
|
||||
|
||||
// Auto-join if nickname exists
|
||||
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
if mySlot < 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
|
||||
ui.SnakePlayerList(sg, mySlot),
|
||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||
)
|
||||
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
content = append(content, ui.SnakeBoard(sg))
|
||||
}
|
||||
|
||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||
}
|
||||
|
||||
// Build keydown attributes with unique __suffix names so the
|
||||
// browser doesn't deduplicate them (all share data-on:keydown).
|
||||
type keyBinding struct {
|
||||
suffix string
|
||||
key string
|
||||
dir snake.Direction
|
||||
}
|
||||
bindings := []keyBinding{
|
||||
{"arrowup", "ArrowUp", snake.DirUp},
|
||||
{"arrowdown", "ArrowDown", snake.DirDown},
|
||||
{"arrowleft", "ArrowLeft", snake.DirLeft},
|
||||
{"arrowright", "ArrowRight", snake.DirRight},
|
||||
{"w", "w", snake.DirUp},
|
||||
{"s", "s", snake.DirDown},
|
||||
{"a", "a", snake.DirLeft},
|
||||
{"d", "d", snake.DirRight},
|
||||
}
|
||||
|
||||
wrapperAttrs := []h.H{
|
||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||
h.Attr("tabindex", "0"),
|
||||
h.Data("on:load", "this.focus()"),
|
||||
}
|
||||
for _, kb := range bindings {
|
||||
expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir))))
|
||||
wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{
|
||||
name: "data-on:keydown__" + kb.suffix,
|
||||
value: expr,
|
||||
}))
|
||||
}
|
||||
|
||||
wrapperAttrs = append(wrapperAttrs, content...)
|
||||
return h.Main(wrapperAttrs...)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
|
||||
296
snake/logic.go
Normal file
296
snake/logic.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package snake
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// SpawnSnakes creates snakes for the given active slot indices, evenly spaced
|
||||
// around the grid perimeter facing inward. Returns a length-8 slice where
|
||||
// only active slots have non-nil entries.
|
||||
func SpawnSnakes(activeSlots []int, width, height int) []*Snake {
|
||||
snakes := make([]*Snake, 8)
|
||||
n := len(activeSlots)
|
||||
if n == 0 {
|
||||
return snakes
|
||||
}
|
||||
|
||||
perimeter := 2*(width+height) - 4
|
||||
spacing := perimeter / n
|
||||
|
||||
for i, slot := range activeSlots {
|
||||
pos := (i * spacing) + spacing/2
|
||||
sp := perimeterToSpawn(pos, width, height)
|
||||
|
||||
body := make([]Point, 3)
|
||||
dx, dy := dirDelta(sp.dir)
|
||||
for j := 0; j < 3; j++ {
|
||||
bx := sp.x - dx*j
|
||||
by := sp.y - dy*j
|
||||
// Clamp to grid bounds
|
||||
if bx < 0 {
|
||||
bx = 0
|
||||
} else if bx >= width {
|
||||
bx = width - 1
|
||||
}
|
||||
if by < 0 {
|
||||
by = 0
|
||||
} else if by >= height {
|
||||
by = height - 1
|
||||
}
|
||||
body[j] = Point{X: bx, Y: by}
|
||||
}
|
||||
snakes[slot] = &Snake{
|
||||
Body: body,
|
||||
Dir: sp.dir,
|
||||
Alive: true,
|
||||
Color: slot + 1,
|
||||
}
|
||||
}
|
||||
|
||||
return snakes
|
||||
}
|
||||
|
||||
type spawnInfo struct {
|
||||
x, y int
|
||||
dir Direction
|
||||
}
|
||||
|
||||
// perimeterToSpawn converts a linear position along the perimeter to coordinates
|
||||
// and an inward-facing direction.
|
||||
func perimeterToSpawn(pos, width, height int) spawnInfo {
|
||||
// Top edge: left to right
|
||||
if pos < width {
|
||||
return spawnInfo{pos, 0, DirDown}
|
||||
}
|
||||
pos -= width
|
||||
|
||||
// Right edge: top to bottom
|
||||
if pos < height-1 {
|
||||
return spawnInfo{width - 1, pos + 1, DirLeft}
|
||||
}
|
||||
pos -= height - 1
|
||||
|
||||
// Bottom edge: right to left
|
||||
if pos < width-1 {
|
||||
return spawnInfo{width - 2 - pos, height - 1, DirUp}
|
||||
}
|
||||
pos -= width - 1
|
||||
|
||||
// Left edge: bottom to top
|
||||
return spawnInfo{0, height - 2 - pos, DirRight}
|
||||
}
|
||||
|
||||
func dirDelta(d Direction) (dx, dy int) {
|
||||
switch d {
|
||||
case DirUp:
|
||||
return 0, -1
|
||||
case DirDown:
|
||||
return 0, 1
|
||||
case DirLeft:
|
||||
return -1, 0
|
||||
case DirRight:
|
||||
return 1, 0
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Tick advances all alive snakes one cell in their direction.
|
||||
func Tick(state *GameState) {
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
dx, dy := dirDelta(s.Dir)
|
||||
head := s.Body[0]
|
||||
newHead := Point{X: head.X + dx, Y: head.Y + dy}
|
||||
s.Body = append([]Point{newHead}, s.Body...)
|
||||
if s.Growing {
|
||||
s.Growing = false
|
||||
} else {
|
||||
s.Body = s.Body[:len(s.Body)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckFood checks if any snake head is on food. Returns indices of eaten food.
|
||||
func CheckFood(state *GameState) []int {
|
||||
eaten := make(map[int]bool)
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
head := s.Body[0]
|
||||
for fi, f := range state.Food {
|
||||
if !eaten[fi] && head.X == f.X && head.Y == f.Y {
|
||||
s.Growing = true
|
||||
eaten[fi] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var indices []int
|
||||
for fi := range eaten {
|
||||
indices = append(indices, fi)
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// RemoveFood removes food items at the given indices.
|
||||
func RemoveFood(state *GameState, indices []int) {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
remove := make(map[int]bool, len(indices))
|
||||
for _, i := range indices {
|
||||
remove[i] = true
|
||||
}
|
||||
var remaining []Point
|
||||
for i, f := range state.Food {
|
||||
if !remove[i] {
|
||||
remaining = append(remaining, f)
|
||||
}
|
||||
}
|
||||
state.Food = remaining
|
||||
}
|
||||
|
||||
// SpawnFood adds food items to maintain the target count.
|
||||
func SpawnFood(state *GameState, playerCount int) {
|
||||
target := playerCount/2 + 1
|
||||
for len(state.Food) < target {
|
||||
p := randomEmptyCell(state)
|
||||
if p == nil {
|
||||
break
|
||||
}
|
||||
state.Food = append(state.Food, *p)
|
||||
}
|
||||
}
|
||||
|
||||
func randomEmptyCell(state *GameState) *Point {
|
||||
occupied := make(map[Point]bool)
|
||||
for _, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.Body {
|
||||
occupied[p] = true
|
||||
}
|
||||
}
|
||||
for _, f := range state.Food {
|
||||
occupied[f] = true
|
||||
}
|
||||
|
||||
var empty []Point
|
||||
for y := 0; y < state.Height; y++ {
|
||||
for x := 0; x < state.Width; x++ {
|
||||
p := Point{X: x, Y: y}
|
||||
if !occupied[p] {
|
||||
empty = append(empty, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(empty) == 0 {
|
||||
return nil
|
||||
}
|
||||
choice := empty[rand.Intn(len(empty))]
|
||||
return &choice
|
||||
}
|
||||
|
||||
// CheckCollisions checks for wall, self, and other-snake collisions.
|
||||
// Returns the set of snake indices that died this tick.
|
||||
func CheckCollisions(state *GameState) map[int]bool {
|
||||
dead := make(map[int]bool)
|
||||
|
||||
for i, s := range state.Snakes {
|
||||
if s == nil || !s.Alive {
|
||||
continue
|
||||
}
|
||||
head := s.Body[0]
|
||||
|
||||
// Wall collision
|
||||
if head.X < 0 || head.X >= state.Width || head.Y < 0 || head.Y >= state.Height {
|
||||
dead[i] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Self-body collision (skip head at index 0)
|
||||
for _, bp := range s.Body[1:] {
|
||||
if head.X == bp.X && head.Y == bp.Y {
|
||||
dead[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dead[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Other snake body collision
|
||||
for j, other := range state.Snakes {
|
||||
if i == j || other == nil || !other.Alive {
|
||||
continue
|
||||
}
|
||||
for _, bp := range other.Body[1:] {
|
||||
if head.X == bp.X && head.Y == bp.Y {
|
||||
dead[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dead[i] {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Head-to-head collision
|
||||
for i, s := range state.Snakes {
|
||||
if s == nil || !s.Alive || dead[i] {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(state.Snakes); j++ {
|
||||
other := state.Snakes[j]
|
||||
if other == nil || !other.Alive || dead[j] {
|
||||
continue
|
||||
}
|
||||
if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y {
|
||||
dead[i] = true
|
||||
dead[j] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dead
|
||||
}
|
||||
|
||||
// MarkDead sets Alive=false for snakes in the dead set.
|
||||
func MarkDead(state *GameState, dead map[int]bool) {
|
||||
for i := range dead {
|
||||
state.Snakes[i].Alive = false
|
||||
}
|
||||
}
|
||||
|
||||
// AliveCount returns the number of living snakes.
|
||||
func AliveCount(state *GameState) int {
|
||||
count := 0
|
||||
for _, s := range state.Snakes {
|
||||
if s != nil && s.Alive {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// LastAlive returns the index of the last alive snake, or -1 if none or multiple.
|
||||
func LastAlive(state *GameState) int {
|
||||
idx := -1
|
||||
for i, s := range state.Snakes {
|
||||
if s != nil && s.Alive {
|
||||
if idx != -1 {
|
||||
return -1
|
||||
}
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// ValidateDirection returns true if the new direction is not a 180 reversal.
|
||||
func ValidateDirection(current, proposed Direction) bool {
|
||||
return !current.Opposite(proposed)
|
||||
}
|
||||
167
snake/loop.go
Normal file
167
snake/loop.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tickInterval = 500 * time.Millisecond
|
||||
countdownSeconds = 10
|
||||
inactivityLimit = 60 * time.Second
|
||||
)
|
||||
|
||||
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
|
||||
si.game.Status = StatusCountdown
|
||||
si.game.CountdownEnd = time.Now().Add(countdownSeconds * time.Second)
|
||||
|
||||
si.loopOnce.Do(func() {
|
||||
si.stopCh = make(chan struct{})
|
||||
go si.runLoop()
|
||||
})
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) runLoop() {
|
||||
si.countdownPhase()
|
||||
|
||||
si.gameMu.RLock()
|
||||
stopped := si.game.Status != StatusInProgress
|
||||
si.gameMu.RUnlock()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
si.gamePhase()
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) countdownPhase() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-si.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
si.gameMu.Lock()
|
||||
|
||||
if si.game.Status != StatusCountdown {
|
||||
si.gameMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
remaining := time.Until(si.game.CountdownEnd)
|
||||
if remaining <= 0 {
|
||||
si.initGame()
|
||||
si.game.Status = StatusInProgress
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
return
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initGame sets up snakes and food for the start of a game.
|
||||
func (si *SnakeGameInstance) initGame() {
|
||||
// Collect active player slots
|
||||
var activeSlots []int
|
||||
for i, p := range si.game.Players {
|
||||
if p != nil {
|
||||
activeSlots = append(activeSlots, i)
|
||||
}
|
||||
}
|
||||
|
||||
state := si.game.State
|
||||
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
|
||||
SpawnFood(state, len(activeSlots))
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) gamePhase() {
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastInput := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-si.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
si.gameMu.Lock()
|
||||
|
||||
if si.game.Status != StatusInProgress {
|
||||
si.gameMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Apply pending directions (iterate all 8 slots)
|
||||
inputReceived := false
|
||||
for i := 0; i < 8; i++ {
|
||||
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
||||
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
|
||||
si.pendingDir[i] = nil
|
||||
inputReceived = true
|
||||
}
|
||||
}
|
||||
if inputReceived {
|
||||
lastInput = time.Now()
|
||||
}
|
||||
|
||||
// Inactivity timeout
|
||||
if time.Since(lastInput) > inactivityLimit {
|
||||
si.game.Status = StatusFinished
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
return
|
||||
}
|
||||
|
||||
state := si.game.State
|
||||
|
||||
// Advance snakes
|
||||
Tick(state)
|
||||
|
||||
// Check collisions first (before food, so dead snakes don't eat)
|
||||
dead := CheckCollisions(state)
|
||||
MarkDead(state, dead)
|
||||
|
||||
// Check food eaten (only by surviving snakes)
|
||||
eaten := CheckFood(state)
|
||||
RemoveFood(state, eaten)
|
||||
SpawnFood(state, si.game.PlayerCount())
|
||||
|
||||
// Check game over
|
||||
alive := AliveCount(state)
|
||||
if alive <= 1 {
|
||||
si.game.Status = StatusFinished
|
||||
winnerIdx := LastAlive(state)
|
||||
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
|
||||
si.game.Winner = si.game.Players[winnerIdx]
|
||||
}
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
|
||||
if alive <= 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
296
snake/store.go
Normal file
296
snake/store.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveSnakeGame(sg *SnakeGame) error
|
||||
LoadSnakeGame(id string) (*SnakeGame, error)
|
||||
SaveSnakePlayer(gameID string, player *Player) error
|
||||
LoadSnakePlayers(gameID string) ([]*Player, error)
|
||||
DeleteSnakeGame(id string) error
|
||||
}
|
||||
|
||||
type SnakeStore struct {
|
||||
games map[string]*SnakeGameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
pubsub PubSub
|
||||
}
|
||||
|
||||
func NewSnakeStore() *SnakeStore {
|
||||
return &SnakeStore{
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPersister(p Persister) {
|
||||
ss.persister = p
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPubSub(ps PubSub) {
|
||||
ss.pubsub = ps
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if ss.pubsub != nil {
|
||||
ss.pubsub.Publish("snake."+gameID, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
|
||||
id := generateID(4)
|
||||
sg := &SnakeGame{
|
||||
ID: id,
|
||||
State: &GameState{
|
||||
Width: width,
|
||||
Height: height,
|
||||
},
|
||||
Players: make([]*Player, 8),
|
||||
Status: StatusWaitingForPlayers,
|
||||
}
|
||||
si := &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
ss.games[id] = si
|
||||
ss.gamesMu.Unlock()
|
||||
|
||||
if ss.persister != nil {
|
||||
ss.persister.SaveSnakeGame(sg)
|
||||
}
|
||||
|
||||
return si
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
ss.gamesMu.RLock()
|
||||
si, ok := ss.games[id]
|
||||
ss.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return si, true
|
||||
}
|
||||
|
||||
if ss.persister == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sg, err := ss.persister.LoadSnakeGame(id)
|
||||
if err != nil || sg == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := ss.persister.LoadSnakePlayers(id)
|
||||
if sg.Players == nil {
|
||||
sg.Players = make([]*Player, 8)
|
||||
}
|
||||
for _, p := range players {
|
||||
if p.Slot >= 0 && p.Slot < 8 {
|
||||
sg.Players[p.Slot] = p
|
||||
}
|
||||
}
|
||||
|
||||
si = &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
ss.games[id] = si
|
||||
ss.gamesMu.Unlock()
|
||||
|
||||
return si, true
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) Delete(id string) error {
|
||||
ss.gamesMu.Lock()
|
||||
si, ok := ss.games[id]
|
||||
delete(ss.games, id)
|
||||
ss.gamesMu.Unlock()
|
||||
|
||||
if ok && si != nil {
|
||||
si.Stop()
|
||||
}
|
||||
|
||||
if ss.persister != nil {
|
||||
return ss.persister.DeleteSnakeGame(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveGames returns metadata of games that can be joined.
|
||||
// Copies game data to avoid holding nested locks.
|
||||
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||
ss.gamesMu.RLock()
|
||||
instances := make([]*SnakeGameInstance, 0, len(ss.games))
|
||||
for _, si := range ss.games {
|
||||
instances = append(instances, si)
|
||||
}
|
||||
ss.gamesMu.RUnlock()
|
||||
|
||||
var games []*SnakeGame
|
||||
for _, si := range instances {
|
||||
si.gameMu.RLock()
|
||||
g := si.game
|
||||
if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown {
|
||||
games = append(games, g)
|
||||
}
|
||||
si.gameMu.RUnlock()
|
||||
}
|
||||
return games
|
||||
}
|
||||
|
||||
type SnakeGameInstance struct {
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
pendingDir [8]*Direction
|
||||
notify func()
|
||||
persister Persister
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) ID() string {
|
||||
si.gameMu.RLock()
|
||||
defer si.gameMu.RUnlock()
|
||||
return si.game.ID
|
||||
}
|
||||
|
||||
// GetGame returns a snapshot of the game state safe for concurrent read.
|
||||
func (si *SnakeGameInstance) GetGame() *SnakeGame {
|
||||
si.gameMu.RLock()
|
||||
defer si.gameMu.RUnlock()
|
||||
return si.game.snapshot()
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
|
||||
si.gameMu.RLock()
|
||||
defer si.gameMu.RUnlock()
|
||||
for i, p := range si.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) Join(player *Player) bool {
|
||||
si.gameMu.Lock()
|
||||
defer si.gameMu.Unlock()
|
||||
|
||||
if si.game.Status == StatusInProgress || si.game.Status == StatusFinished {
|
||||
return false
|
||||
}
|
||||
|
||||
slot := -1
|
||||
for i, p := range si.game.Players {
|
||||
if p == nil {
|
||||
slot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if slot == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
player.Slot = slot
|
||||
si.game.Players[slot] = player
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakePlayer(si.game.ID, player)
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
|
||||
si.notify()
|
||||
|
||||
if si.game.PlayerCount() >= 2 {
|
||||
si.startOrResetCountdownLocked()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SetDirection buffers a direction change for the given slot.
|
||||
// The write happens under the game lock to avoid a data race with the game loop.
|
||||
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
|
||||
if slot < 0 || slot >= 8 {
|
||||
return
|
||||
}
|
||||
si.gameMu.Lock()
|
||||
defer si.gameMu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
si.pendingDir[slot] = &dir
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) Stop() {
|
||||
if si.stopCh != nil {
|
||||
select {
|
||||
case <-si.stopCh:
|
||||
default:
|
||||
close(si.stopCh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
||||
si.gameMu.Lock()
|
||||
|
||||
if !si.game.IsFinished() || si.game.RematchGameID != nil {
|
||||
si.gameMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture state needed, then release lock before calling store.Create
|
||||
// (which acquires gamesMu) to avoid lock ordering deadlock.
|
||||
width := si.game.State.Width
|
||||
height := si.game.State.Height
|
||||
si.gameMu.Unlock()
|
||||
|
||||
newSI := si.store.Create(width, height)
|
||||
newID := newSI.ID()
|
||||
|
||||
si.gameMu.Lock()
|
||||
// Re-check after reacquiring lock
|
||||
if si.game.RematchGameID != nil {
|
||||
si.gameMu.Unlock()
|
||||
return newSI
|
||||
}
|
||||
si.game.RematchGameID = &newID
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
|
||||
si.notify()
|
||||
return newSI
|
||||
}
|
||||
|
||||
func generateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
148
snake/types.go
Normal file
148
snake/types.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirUp Direction = iota
|
||||
DirDown
|
||||
DirLeft
|
||||
DirRight
|
||||
)
|
||||
|
||||
// Opposite returns true if a and b are 180-degree reversals.
|
||||
func (d Direction) Opposite(other Direction) bool {
|
||||
switch d {
|
||||
case DirUp:
|
||||
return other == DirDown
|
||||
case DirDown:
|
||||
return other == DirUp
|
||||
case DirLeft:
|
||||
return other == DirRight
|
||||
case DirRight:
|
||||
return other == DirLeft
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type Snake struct {
|
||||
Body []Point `json:"body"`
|
||||
Dir Direction `json:"dir"`
|
||||
Alive bool `json:"alive"`
|
||||
Growing bool `json:"growing"`
|
||||
Color int `json:"color"` // 1-8
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Snakes []*Snake `json:"snakes"`
|
||||
Food []Point `json:"food"`
|
||||
}
|
||||
|
||||
func (gs *GameState) ToJSON() string {
|
||||
data, _ := json.Marshal(gs)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func GameStateFromJSON(data string) (*GameState, error) {
|
||||
var gs GameState
|
||||
if err := json.Unmarshal([]byte(data), &gs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gs, nil
|
||||
}
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusWaitingForPlayers Status = iota
|
||||
StatusCountdown
|
||||
StatusInProgress
|
||||
StatusFinished
|
||||
)
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
UserID *string
|
||||
Nickname string
|
||||
Slot int // 0-7
|
||||
}
|
||||
|
||||
type SnakeGame struct {
|
||||
ID string
|
||||
State *GameState
|
||||
Players []*Player // up to 8
|
||||
Status Status
|
||||
Winner *Player // nil if draw
|
||||
CountdownEnd time.Time // when countdown reaches 0
|
||||
RematchGameID *string
|
||||
}
|
||||
|
||||
func (sg *SnakeGame) IsFinished() bool {
|
||||
return sg.Status == StatusFinished
|
||||
}
|
||||
|
||||
func (sg *SnakeGame) PlayerCount() int {
|
||||
count := 0
|
||||
for _, p := range sg.Players {
|
||||
if p != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Grid presets
|
||||
type GridPreset struct {
|
||||
Name string
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
var GridPresets = []GridPreset{
|
||||
{Name: "Small", Width: 20, Height: 20},
|
||||
{Name: "Medium", Width: 30, Height: 20},
|
||||
{Name: "Large", Width: 40, Height: 20},
|
||||
}
|
||||
|
||||
// snapshot returns a shallow copy of the game safe for reading outside the lock.
|
||||
// Slices and pointers are shared but the top-level struct is copied.
|
||||
func (sg *SnakeGame) snapshot() *SnakeGame {
|
||||
cp := *sg
|
||||
if sg.State != nil {
|
||||
stateCp := *sg.State
|
||||
// Copy slices so the caller's iteration is safe
|
||||
stateCp.Snakes = make([]*Snake, len(sg.State.Snakes))
|
||||
copy(stateCp.Snakes, sg.State.Snakes)
|
||||
stateCp.Food = make([]Point, len(sg.State.Food))
|
||||
copy(stateCp.Food, sg.State.Food)
|
||||
cp.State = &stateCp
|
||||
}
|
||||
cp.Players = make([]*Player, len(sg.Players))
|
||||
copy(cp.Players, sg.Players)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Snake colors (hex values for CSS)
|
||||
var SnakeColors = []string{
|
||||
"#00b894", // 1: Green
|
||||
"#e17055", // 2: Orange
|
||||
"#0984e3", // 3: Blue
|
||||
"#6c5ce7", // 4: Purple
|
||||
"#fd79a8", // 5: Pink
|
||||
"#00cec9", // 6: Cyan
|
||||
"#d63031", // 7: Red
|
||||
"#fdcb6e", // 8: Yellow
|
||||
}
|
||||
61
ui/lobby.go
61
ui/lobby.go
@@ -1,20 +1,63 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem, deleteGameClick func(id string) h.H) h.H {
|
||||
type LobbyProps struct {
|
||||
NicknameBind h.H
|
||||
CreateGameKeyDown h.H
|
||||
CreateGameClick h.H
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
LogoutClick h.H
|
||||
UserGames []GameListItem
|
||||
DeleteGameClick func(id string) h.H
|
||||
ActiveTab string
|
||||
TabClickConnect4 h.H
|
||||
TabClickSnake h.H
|
||||
SnakeNicknameBind h.H
|
||||
SnakePresetClicks []h.H
|
||||
ActiveSnakeGames []*snake.SnakeGame
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
var authSection h.H
|
||||
if isLoggedIn {
|
||||
authSection = AuthHeader(username, logoutClick)
|
||||
if p.IsLoggedIn {
|
||||
authSection = AuthHeader(p.Username, p.LogoutClick)
|
||||
} else {
|
||||
authSection = GuestBanner()
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
connect4Class := "tab"
|
||||
snakeClass := "tab"
|
||||
if p.ActiveTab == "snake" {
|
||||
snakeClass += " tab-active"
|
||||
} else {
|
||||
connect4Class += " tab-active"
|
||||
}
|
||||
|
||||
var tabContent h.H
|
||||
if p.ActiveTab == "snake" {
|
||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames)
|
||||
} else {
|
||||
tabContent = connect4LobbyContent(p)
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||
authSection,
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), h.Text("Game Lobby")),
|
||||
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), h.Text("Connect 4"), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("Snake"), p.TabClickSnake),
|
||||
),
|
||||
tabContent,
|
||||
)
|
||||
}
|
||||
|
||||
func connect4LobbyContent(p LobbyProps) h.H {
|
||||
return h.Div(
|
||||
h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
@@ -24,19 +67,19 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
p.NicknameBind,
|
||||
h.Attr("required"),
|
||||
createGameKeyDown,
|
||||
p.CreateGameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Create Game"),
|
||||
createGameClick,
|
||||
p.CreateGameClick,
|
||||
),
|
||||
),
|
||||
GameList(userGames, deleteGameClick),
|
||||
GameList(p.UserGames, p.DeleteGameClick),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
92
ui/snakeboard.go
Normal file
92
ui/snakeboard.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
state := sg.State
|
||||
if state == nil || sg.Status != snake.StatusInProgress && sg.Status != snake.StatusFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a lookup grid for rendering
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cell size scales with grid dimensions
|
||||
cellSize := 20
|
||||
if state.Width <= 20 {
|
||||
cellSize = 24
|
||||
}
|
||||
|
||||
var rows []h.H
|
||||
for y := 0; y < state.Height; y++ {
|
||||
var cells []h.H
|
||||
for x := 0; x < state.Width; x++ {
|
||||
ci := grid[y][x]
|
||||
class := "snake-cell"
|
||||
style := fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize)
|
||||
|
||||
switch {
|
||||
case ci.snakeIdx == -2:
|
||||
class += " snake-food"
|
||||
case ci.snakeIdx >= 0:
|
||||
s := state.Snakes[ci.snakeIdx]
|
||||
colorIdx := ci.snakeIdx
|
||||
if colorIdx < len(snake.SnakeColors) {
|
||||
bg := snake.SnakeColors[colorIdx]
|
||||
style += fmt.Sprintf("background:%s;", bg)
|
||||
}
|
||||
if !s.Alive {
|
||||
class += " snake-dead"
|
||||
}
|
||||
if ci.isHead {
|
||||
class += " snake-head"
|
||||
}
|
||||
}
|
||||
|
||||
cells = append(cells, h.Div(h.Class(class), h.Attr("style", style)))
|
||||
}
|
||||
rowAttrs := append([]h.H{h.Class("snake-row")}, cells...)
|
||||
rows = append(rows, h.Div(rowAttrs...))
|
||||
}
|
||||
|
||||
boardStyle := fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", state.Width)
|
||||
attrs := []h.H{
|
||||
h.Class("snake-board"),
|
||||
h.Attr("style", boardStyle),
|
||||
}
|
||||
attrs = append(attrs, rows...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
74
ui/snakelobby.go
Normal file
74
ui/snakelobby.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H {
|
||||
var presetButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(presetClicks) {
|
||||
click = presetClicks[i]
|
||||
}
|
||||
presetButtons = append(presetButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-primary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
createSection := h.Div(h.Class("mb-6"),
|
||||
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.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("snake-nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
|
||||
)
|
||||
|
||||
var gameListEl h.H
|
||||
if len(activeGames) > 0 {
|
||||
var items []h.H
|
||||
for _, g := range activeGames {
|
||||
playerCount := g.PlayerCount()
|
||||
sizeLabel := fmt.Sprintf("%d×%d", g.State.Width, g.State.Height)
|
||||
statusLabel := "Waiting"
|
||||
if g.Status == snake.StatusCountdown {
|
||||
statusLabel = "Starting soon"
|
||||
}
|
||||
items = append(items, h.A(
|
||||
h.Href("/snake/"+g.ID),
|
||||
h.Class("flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"),
|
||||
h.Span(h.Text(fmt.Sprintf("%s — %d/8 players", sizeLabel, playerCount))),
|
||||
h.Span(h.Class("text-sm opacity-60"), h.Text(statusLabel)),
|
||||
))
|
||||
}
|
||||
listAttrs := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
gameListEl = h.Div(h.Class("mt-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2 text-center"), h.Text("Join a Game")),
|
||||
h.Div(listAttrs...),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
createSection,
|
||||
gameListEl,
|
||||
)
|
||||
}
|
||||
146
ui/snakestatus.go
Normal file
146
ui/snakestatus.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Waiting for players..."),
|
||||
)
|
||||
|
||||
case snake.StatusCountdown:
|
||||
remaining := time.Until(sg.CountdownEnd)
|
||||
secs := int(math.Ceil(remaining.Seconds()))
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
return h.Div(h.Class("alert alert-info text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Starting in %d...", secs)),
|
||||
)
|
||||
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[mySlot]
|
||||
if s != nil && !s.Alive {
|
||||
return h.Div(h.Class("alert alert-error text-xl font-bold"),
|
||||
h.Text("You're out!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text("Go!"),
|
||||
)
|
||||
|
||||
case snake.StatusFinished:
|
||||
var msg string
|
||||
var class string
|
||||
if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
msg = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
msg = sg.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
} else {
|
||||
msg = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{h.Class(class), h.Text(msg)}
|
||||
|
||||
if sg.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/snake/"+*sg.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if rematchClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
rematchClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
|
||||
var items []h.H
|
||||
|
||||
for i, p := range sg.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
colorHex := "#666"
|
||||
if i < len(snake.SnakeColors) {
|
||||
colorHex = snake.SnakeColors[i]
|
||||
}
|
||||
|
||||
name := p.Nickname
|
||||
if i == mySlot {
|
||||
name += " (You)"
|
||||
}
|
||||
|
||||
var statusEl h.H
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[i]
|
||||
if s != nil {
|
||||
if s.Alive {
|
||||
length := len(s.Body)
|
||||
statusEl = h.Span(h.Class("text-sm opacity-60"), h.Text(fmt.Sprintf(" (%d)", length)))
|
||||
} else {
|
||||
statusEl = h.Span(h.Class("text-sm opacity-40"), h.Text(" (dead)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chipStyle := fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", colorHex)
|
||||
|
||||
items = append(items, h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Attr("style", chipStyle)),
|
||||
h.Span(h.Text(name)),
|
||||
statusEl,
|
||||
))
|
||||
}
|
||||
|
||||
listAttrs := []h.H{h.Class("flex flex-wrap gap-4 mb-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
return h.Div(listAttrs...)
|
||||
}
|
||||
|
||||
func SnakeInviteLink(gameID string) h.H {
|
||||
fullURL := baseURL + "/snake/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
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.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user