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 { width: 20px; height: 20px; border-radius: 50%; background: #666; }
|
||||||
.player-chip.red { background: #dc2626; }
|
.player-chip.red { background: #dc2626; }
|
||||||
.player-chip.yellow { background: #facc15; }
|
.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;
|
--color-white: #fff;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--container-sm: 24rem;
|
--container-sm: 24rem;
|
||||||
|
--container-md: 28rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
@@ -179,6 +180,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@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 {
|
.btn {
|
||||||
:where(&) {
|
:where(&) {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@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 {
|
.input {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
cursor: text;
|
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 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.mt-4 {
|
.mt-4 {
|
||||||
margin-top: calc(var(--spacing) * 4);
|
margin-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.mt-8 {
|
.mt-8 {
|
||||||
margin-top: calc(var(--spacing) * 8);
|
margin-top: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@@ -812,6 +986,9 @@
|
|||||||
.mb-4 {
|
.mb-4 {
|
||||||
margin-bottom: calc(var(--spacing) * 4);
|
margin-bottom: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.ml-4 {
|
.ml-4 {
|
||||||
margin-left: calc(var(--spacing) * 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 {
|
.alert {
|
||||||
border-width: var(--border);
|
border-width: var(--border);
|
||||||
border-color: var(--alert-border-color, var(--color-base-200));
|
border-color: var(--alert-border-color, var(--color-base-200));
|
||||||
@@ -947,6 +1135,9 @@
|
|||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.btn-square {
|
.btn-square {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
padding-inline: calc(0.25rem * 0);
|
padding-inline: calc(0.25rem * 0);
|
||||||
@@ -957,6 +1148,9 @@
|
|||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.max-w-md {
|
||||||
|
max-width: var(--container-md);
|
||||||
|
}
|
||||||
.max-w-sm {
|
.max-w-sm {
|
||||||
max-width: var(--container-sm);
|
max-width: var(--container-sm);
|
||||||
}
|
}
|
||||||
@@ -984,6 +1178,9 @@
|
|||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1218,9 @@
|
|||||||
.p-2 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1273,13 @@
|
|||||||
--alert-color: var(--color-error);
|
--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 {
|
.alert-success {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
color: var(--color-success-content);
|
color: var(--color-success-content);
|
||||||
@@ -1099,6 +1306,9 @@
|
|||||||
.no-underline {
|
.no-underline {
|
||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
}
|
}
|
||||||
|
.opacity-40 {
|
||||||
|
opacity: 40%;
|
||||||
|
}
|
||||||
.opacity-60 {
|
.opacity-60 {
|
||||||
opacity: 60%;
|
opacity: 60%;
|
||||||
}
|
}
|
||||||
@@ -1226,6 +1436,36 @@
|
|||||||
.player-chip.yellow {
|
.player-chip.yellow {
|
||||||
background: #facc15;
|
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 {
|
@layer base {
|
||||||
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
|
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const createGame = `-- name: CreateGame :one
|
const createGame = `-- name: CreateGame :one
|
||||||
INSERT INTO games (id, board, current_turn, status)
|
INSERT INTO games (id, board, current_turn, status)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id
|
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 {
|
type CreateGameParams struct {
|
||||||
@@ -41,6 +41,10 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RematchGameID,
|
&i.RematchGameID,
|
||||||
|
&i.GameType,
|
||||||
|
&i.GridWidth,
|
||||||
|
&i.GridHeight,
|
||||||
|
&i.MaxPlayers,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -81,7 +85,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActiveGames = `-- name: GetActiveGames :many
|
const getActiveGames = `-- name: GetActiveGames :many
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id 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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RematchGameID,
|
&i.RematchGameID,
|
||||||
|
&i.GameType,
|
||||||
|
&i.GridWidth,
|
||||||
|
&i.GridHeight,
|
||||||
|
&i.MaxPlayers,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -118,7 +126,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGame = `-- name: GetGame :one
|
const getGame = `-- name: GetGame :one
|
||||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id 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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RematchGameID,
|
&i.RematchGameID,
|
||||||
|
&i.GameType,
|
||||||
|
&i.GridWidth,
|
||||||
|
&i.GridHeight,
|
||||||
|
&i.MaxPlayers,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -174,7 +186,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
const getGamesByUserID = `-- name: GetGamesByUserID :many
|
||||||
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id 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
|
JOIN game_players gp ON g.id = gp.game_id
|
||||||
WHERE gp.user_id = ?
|
WHERE gp.user_id = ?
|
||||||
ORDER BY g.updated_at DESC
|
ORDER BY g.updated_at DESC
|
||||||
@@ -199,6 +211,10 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RematchGameID,
|
&i.RematchGameID,
|
||||||
|
&i.GameType,
|
||||||
|
&i.GridWidth,
|
||||||
|
&i.GridHeight,
|
||||||
|
&i.MaxPlayers,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -224,7 +240,7 @@ SELECT
|
|||||||
FROM games g
|
FROM games g
|
||||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
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
|
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
|
ORDER BY g.updated_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type Game struct {
|
|||||||
CreatedAt sql.NullTime
|
CreatedAt sql.NullTime
|
||||||
UpdatedAt sql.NullTime
|
UpdatedAt sql.NullTime
|
||||||
RematchGameID sql.NullString
|
RematchGameID sql.NullString
|
||||||
|
GameType string
|
||||||
|
GridWidth sql.NullInt64
|
||||||
|
GridHeight sql.NullInt64
|
||||||
|
MaxPlayers int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePlayer struct {
|
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/db/gen"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/c4/game"
|
||||||
|
"github.com/ryanhamamura/c4/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GamePersister struct {
|
type GamePersister struct {
|
||||||
@@ -138,3 +139,144 @@ func (p *GamePersister) DeleteGame(id string) error {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
return p.queries.DeleteGame(ctx, id)
|
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 = ?;
|
DELETE FROM games WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetActiveGames :many
|
-- name: GetActiveGames :many
|
||||||
SELECT * FROM games WHERE status < 2;
|
SELECT * FROM games WHERE game_type = 'connect4' AND status < 2;
|
||||||
|
|
||||||
-- name: CreateGamePlayer :exec
|
-- name: CreateGamePlayer :exec
|
||||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||||
@@ -41,5 +41,5 @@ SELECT
|
|||||||
FROM games g
|
FROM games g
|
||||||
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
|
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
|
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;
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
g "maragu.dev/gomponents"
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/auth"
|
"github.com/ryanhamamura/c4/auth"
|
||||||
"github.com/ryanhamamura/c4/db"
|
"github.com/ryanhamamura/c4/db"
|
||||||
"github.com/ryanhamamura/c4/db/gen"
|
"github.com/ryanhamamura/c4/db/gen"
|
||||||
"github.com/ryanhamamura/c4/game"
|
"github.com/ryanhamamura/c4/game"
|
||||||
|
"github.com/ryanhamamura/c4/snake"
|
||||||
"github.com/ryanhamamura/c4/ui"
|
"github.com/ryanhamamura/c4/ui"
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
@@ -19,11 +27,43 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var store = game.NewGameStore()
|
var store = game.NewGameStore()
|
||||||
|
var snakeStore = snake.NewSnakeStore()
|
||||||
var queries *gen.Queries
|
var queries *gen.Queries
|
||||||
|
|
||||||
//go:embed assets/css/output.css
|
//go:embed assets/css/output.css
|
||||||
var daisyUICSS []byte
|
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) {
|
func DaisyUIPlugin(v *via.V) {
|
||||||
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/css")
|
w.Header().Set("Content-Type", "text/css")
|
||||||
@@ -38,6 +78,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
queries = gen.New(db.DB)
|
queries = gen.New(db.DB)
|
||||||
store.SetPersister(db.NewGamePersister(queries))
|
store.SetPersister(db.NewGamePersister(queries))
|
||||||
|
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||||
|
|
||||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,18 +91,19 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
store.SetPubSub(ns)
|
store.SetPubSub(ns)
|
||||||
|
snakeStore.SetPubSub(ns)
|
||||||
|
|
||||||
v := via.New()
|
v := via.New()
|
||||||
v.Config(via.Options{
|
v.Config(via.Options{
|
||||||
LogLevel: via.LogLevelDebug,
|
LogLevel: via.LogLevelDebug,
|
||||||
DocumentTitle: "Connect 4",
|
DocumentTitle: "Game Lobby",
|
||||||
ServerAddress: ":7331",
|
ServerAddress: ":7331",
|
||||||
SessionManager: sessionManager,
|
SessionManager: sessionManager,
|
||||||
PubSub: ns,
|
PubSub: ns,
|
||||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Home page - enter nickname and create game
|
// Home page - tabbed lobby
|
||||||
v.Page("/", func(c *via.Context) {
|
v.Page("/", func(c *via.Context) {
|
||||||
userID := c.Session().GetString("user_id")
|
userID := c.Session().GetString("user_id")
|
||||||
username := c.Session().GetString("username")
|
username := c.Session().GetString("username")
|
||||||
@@ -89,6 +131,7 @@ func main() {
|
|||||||
if isLoggedIn {
|
if isLoggedIn {
|
||||||
nickname = c.Signal(username)
|
nickname = c.Signal(username)
|
||||||
}
|
}
|
||||||
|
activeTab := c.Signal("connect4")
|
||||||
|
|
||||||
logout := c.Action(func() {
|
logout := c.Action(func() {
|
||||||
c.Session().Clear()
|
c.Session().Clear()
|
||||||
@@ -118,17 +161,53 @@ func main() {
|
|||||||
}).OnClick()
|
}).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 {
|
c.View(func() h.H {
|
||||||
return ui.LobbyView(
|
return ui.LobbyView(ui.LobbyProps{
|
||||||
nickname.Bind(),
|
NicknameBind: nickname.Bind(),
|
||||||
createGame.OnKeyDown("Enter"),
|
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||||
createGame.OnClick(),
|
CreateGameClick: createGame.OnClick(),
|
||||||
isLoggedIn,
|
IsLoggedIn: isLoggedIn,
|
||||||
username,
|
Username: username,
|
||||||
logout.OnClick(),
|
LogoutClick: logout.OnClick(),
|
||||||
userGames,
|
UserGames: userGames,
|
||||||
deleteGame,
|
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) {
|
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||||
gameID := c.GetPathParam("game_id")
|
gameID := c.GetPathParam("game_id")
|
||||||
sessionNickname := c.Session().GetString("nickname")
|
sessionNickname := c.Session().GetString("nickname")
|
||||||
@@ -278,19 +357,16 @@ func main() {
|
|||||||
var gi *game.GameInstance
|
var gi *game.GameInstance
|
||||||
var gameExists bool
|
var gameExists bool
|
||||||
|
|
||||||
// Look up game (may not exist during warmup or invalid ID)
|
|
||||||
if gameID != "" {
|
if gameID != "" {
|
||||||
gi, gameExists = store.Get(gameID)
|
gi, gameExists = store.Get(gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a stable player ID for this session
|
|
||||||
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
||||||
if playerID == "" {
|
if playerID == "" {
|
||||||
playerID = game.PlayerID(game.GenerateID(8))
|
playerID = game.PlayerID(game.GenerateID(8))
|
||||||
c.Session().Set("player_id", string(playerID))
|
c.Session().Set("player_id", string(playerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use user_id as player_id if logged in
|
|
||||||
if sessionUserID != "" {
|
if sessionUserID != "" {
|
||||||
playerID = game.PlayerID(sessionUserID)
|
playerID = game.PlayerID(sessionUserID)
|
||||||
}
|
}
|
||||||
@@ -305,7 +381,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
c.Session().Set("nickname", name)
|
c.Session().Set("nickname", name)
|
||||||
|
|
||||||
// Try to join if not already in game
|
|
||||||
if gi.GetPlayerColor(playerID) == 0 {
|
if gi.GetPlayerColor(playerID) == 0 {
|
||||||
player := &game.Player{
|
player := &game.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
@@ -344,12 +419,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to game updates so the opponent's moves trigger a re-render
|
|
||||||
if gameExists {
|
if gameExists {
|
||||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
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 {
|
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||||
player := &game.Player{
|
player := &game.Player{
|
||||||
ID: playerID,
|
ID: playerID,
|
||||||
@@ -364,7 +437,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
// Game not found - redirect to home
|
|
||||||
if !gameExists {
|
if !gameExists {
|
||||||
c.Redirect("/")
|
c.Redirect("/")
|
||||||
return h.Div()
|
return h.Div()
|
||||||
@@ -372,9 +444,7 @@ func main() {
|
|||||||
|
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
|
|
||||||
// Need nickname first / not joined yet
|
|
||||||
if myColor == 0 {
|
if myColor == 0 {
|
||||||
// Unauthenticated user who hasn't chosen to continue as guest
|
|
||||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||||
return ui.GameJoinPrompt(
|
return ui.GameJoinPrompt(
|
||||||
goToLogin.OnClick(),
|
goToLogin.OnClick(),
|
||||||
@@ -391,7 +461,6 @@ func main() {
|
|||||||
|
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
|
|
||||||
// Create column click function
|
|
||||||
columnClick := func(col int) h.H {
|
columnClick := func(col int) h.H {
|
||||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||||
}
|
}
|
||||||
@@ -404,7 +473,6 @@ func main() {
|
|||||||
ui.BoardComponent(g, columnClick, myColor),
|
ui.BoardComponent(g, columnClick, myColor),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show invite link when waiting for opponent
|
|
||||||
if g.Status == game.StatusWaitingForPlayer {
|
if g.Status == game.StatusWaitingForPlayer {
|
||||||
content = append(content, ui.InviteLink(g.ID))
|
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()
|
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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/ryanhamamura/c4/snake"
|
||||||
"github.com/ryanhamamura/via/h"
|
"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
|
var authSection h.H
|
||||||
if isLoggedIn {
|
if p.IsLoggedIn {
|
||||||
authSection = AuthHeader(username, logoutClick)
|
authSection = AuthHeader(p.Username, p.LogoutClick)
|
||||||
} else {
|
} else {
|
||||||
authSection = GuestBanner()
|
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,
|
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.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
|
||||||
h.Form(
|
h.Form(
|
||||||
h.FieldSet(h.Class("fieldset"),
|
h.FieldSet(h.Class("fieldset"),
|
||||||
@@ -24,19 +67,19 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
|
|||||||
h.ID("nickname"),
|
h.ID("nickname"),
|
||||||
h.Type("text"),
|
h.Type("text"),
|
||||||
h.Placeholder("Enter your nickname"),
|
h.Placeholder("Enter your nickname"),
|
||||||
nicknameBind,
|
p.NicknameBind,
|
||||||
h.Attr("required"),
|
h.Attr("required"),
|
||||||
createGameKeyDown,
|
p.CreateGameKeyDown,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h.Button(
|
h.Button(
|
||||||
h.Class("btn btn-primary w-full"),
|
h.Class("btn btn-primary w-full"),
|
||||||
h.Type("button"),
|
h.Type("button"),
|
||||||
h.Text("Create Game"),
|
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