From 7e7866453428adb41c42524512c56ccbf775a902 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:26:28 -1000 Subject: [PATCH] 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). --- assets/css/input.css | 24 +++ assets/css/output.css | 240 ++++++++++++++++++++++++++ db/gen/games.sql.go | 26 ++- db/gen/models.go | 4 + db/gen/snake_games.sql.go | 263 ++++++++++++++++++++++++++++ db/migrations/003_add_snake.sql | 13 ++ db/persister.go | 142 +++++++++++++++ db/queries/games.sql | 4 +- db/queries/snake_games.sql | 37 ++++ main.go | 296 +++++++++++++++++++++++++++++--- snake/logic.go | 296 ++++++++++++++++++++++++++++++++ snake/loop.go | 167 ++++++++++++++++++ snake/store.go | 296 ++++++++++++++++++++++++++++++++ snake/types.go | 148 ++++++++++++++++ ui/lobby.go | 61 ++++++- ui/snakeboard.go | 92 ++++++++++ ui/snakelobby.go | 74 ++++++++ ui/snakestatus.go | 146 ++++++++++++++++ 18 files changed, 2289 insertions(+), 40 deletions(-) create mode 100644 db/gen/snake_games.sql.go create mode 100644 db/migrations/003_add_snake.sql create mode 100644 db/queries/snake_games.sql create mode 100644 snake/logic.go create mode 100644 snake/loop.go create mode 100644 snake/store.go create mode 100644 snake/types.go create mode 100644 ui/snakeboard.go create mode 100644 ui/snakelobby.go create mode 100644 ui/snakestatus.go diff --git a/assets/css/input.css b/assets/css/input.css index 7fcc3c7..b21a793 100644 --- a/assets/css/input.css +++ b/assets/css/input.css @@ -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; } diff --git a/assets/css/output.css b/assets/css/output.css index b728ae2..f2e04e2 100644 --- a/assets/css/output.css +++ b/assets/css/output.css @@ -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; diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go index 4a23cf9..c4cbd16 100644 --- a/db/gen/games.sql.go +++ b/db/gen/games.sql.go @@ -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 ` diff --git a/db/gen/models.go b/db/gen/models.go index cfe2ab0..862a802 100644 --- a/db/gen/models.go +++ b/db/gen/models.go @@ -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 { diff --git a/db/gen/snake_games.sql.go b/db/gen/snake_games.sql.go new file mode 100644 index 0000000..accc7ea --- /dev/null +++ b/db/gen/snake_games.sql.go @@ -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 +} diff --git a/db/migrations/003_add_snake.sql b/db/migrations/003_add_snake.sql new file mode 100644 index 0000000..4c87794 --- /dev/null +++ b/db/migrations/003_add_snake.sql @@ -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; diff --git a/db/persister.go b/db/persister.go index 7e78c27..a145f9f 100644 --- a/db/persister.go +++ b/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) +} diff --git a/db/queries/games.sql b/db/queries/games.sql index 479e00b..c81c146 100644 --- a/db/queries/games.sql +++ b/db/queries/games.sql @@ -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; diff --git a/db/queries/snake_games.sql b/db/queries/snake_games.sql new file mode 100644 index 0000000..3d9e09a --- /dev/null +++ b/db/queries/snake_games.sql @@ -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; diff --git a/main.go b/main.go index 8f6fc32..51d9671 100644 --- a/main.go +++ b/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() } diff --git a/snake/logic.go b/snake/logic.go new file mode 100644 index 0000000..fff75ed --- /dev/null +++ b/snake/logic.go @@ -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) +} diff --git a/snake/loop.go b/snake/loop.go new file mode 100644 index 0000000..77aafed --- /dev/null +++ b/snake/loop.go @@ -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 + } + } + } +} diff --git a/snake/store.go b/snake/store.go new file mode 100644 index 0000000..9438cda --- /dev/null +++ b/snake/store.go @@ -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) +} diff --git a/snake/types.go b/snake/types.go new file mode 100644 index 0000000..0359398 --- /dev/null +++ b/snake/types.go @@ -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 +} diff --git a/ui/lobby.go b/ui/lobby.go index 5e7f49f..94d8bbb 100644 --- a/ui/lobby.go +++ b/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), ) } diff --git a/ui/snakeboard.go b/ui/snakeboard.go new file mode 100644 index 0000000..f4076c3 --- /dev/null +++ b/ui/snakeboard.go @@ -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...) +} diff --git a/ui/snakelobby.go b/ui/snakelobby.go new file mode 100644 index 0000000..24fd505 --- /dev/null +++ b/ui/snakelobby.go @@ -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, + ) +} diff --git a/ui/snakestatus.go b/ui/snakestatus.go new file mode 100644 index 0000000..d323fc1 --- /dev/null +++ b/ui/snakestatus.go @@ -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+"')"), + ), + ) +}