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
ui/lobby.go
61
ui/lobby.go
@@ -1,20 +1,63 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem, deleteGameClick func(id string) h.H) h.H {
|
||||
type LobbyProps struct {
|
||||
NicknameBind h.H
|
||||
CreateGameKeyDown h.H
|
||||
CreateGameClick h.H
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
LogoutClick h.H
|
||||
UserGames []GameListItem
|
||||
DeleteGameClick func(id string) h.H
|
||||
ActiveTab string
|
||||
TabClickConnect4 h.H
|
||||
TabClickSnake h.H
|
||||
SnakeNicknameBind h.H
|
||||
SnakePresetClicks []h.H
|
||||
ActiveSnakeGames []*snake.SnakeGame
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
var authSection h.H
|
||||
if isLoggedIn {
|
||||
authSection = AuthHeader(username, logoutClick)
|
||||
if p.IsLoggedIn {
|
||||
authSection = AuthHeader(p.Username, p.LogoutClick)
|
||||
} else {
|
||||
authSection = GuestBanner()
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
connect4Class := "tab"
|
||||
snakeClass := "tab"
|
||||
if p.ActiveTab == "snake" {
|
||||
snakeClass += " tab-active"
|
||||
} else {
|
||||
connect4Class += " tab-active"
|
||||
}
|
||||
|
||||
var tabContent h.H
|
||||
if p.ActiveTab == "snake" {
|
||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames)
|
||||
} else {
|
||||
tabContent = connect4LobbyContent(p)
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||
authSection,
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), h.Text("Game Lobby")),
|
||||
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), h.Text("Connect 4"), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("Snake"), p.TabClickSnake),
|
||||
),
|
||||
tabContent,
|
||||
)
|
||||
}
|
||||
|
||||
func connect4LobbyContent(p LobbyProps) h.H {
|
||||
return h.Div(
|
||||
h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
@@ -24,19 +67,19 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
p.NicknameBind,
|
||||
h.Attr("required"),
|
||||
createGameKeyDown,
|
||||
p.CreateGameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Create Game"),
|
||||
createGameClick,
|
||||
p.CreateGameClick,
|
||||
),
|
||||
),
|
||||
GameList(userGames, deleteGameClick),
|
||||
GameList(p.UserGames, p.DeleteGameClick),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
92
ui/snakeboard.go
Normal file
92
ui/snakeboard.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
state := sg.State
|
||||
if state == nil || sg.Status != snake.StatusInProgress && sg.Status != snake.StatusFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a lookup grid for rendering
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cell size scales with grid dimensions
|
||||
cellSize := 20
|
||||
if state.Width <= 20 {
|
||||
cellSize = 24
|
||||
}
|
||||
|
||||
var rows []h.H
|
||||
for y := 0; y < state.Height; y++ {
|
||||
var cells []h.H
|
||||
for x := 0; x < state.Width; x++ {
|
||||
ci := grid[y][x]
|
||||
class := "snake-cell"
|
||||
style := fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize)
|
||||
|
||||
switch {
|
||||
case ci.snakeIdx == -2:
|
||||
class += " snake-food"
|
||||
case ci.snakeIdx >= 0:
|
||||
s := state.Snakes[ci.snakeIdx]
|
||||
colorIdx := ci.snakeIdx
|
||||
if colorIdx < len(snake.SnakeColors) {
|
||||
bg := snake.SnakeColors[colorIdx]
|
||||
style += fmt.Sprintf("background:%s;", bg)
|
||||
}
|
||||
if !s.Alive {
|
||||
class += " snake-dead"
|
||||
}
|
||||
if ci.isHead {
|
||||
class += " snake-head"
|
||||
}
|
||||
}
|
||||
|
||||
cells = append(cells, h.Div(h.Class(class), h.Attr("style", style)))
|
||||
}
|
||||
rowAttrs := append([]h.H{h.Class("snake-row")}, cells...)
|
||||
rows = append(rows, h.Div(rowAttrs...))
|
||||
}
|
||||
|
||||
boardStyle := fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", state.Width)
|
||||
attrs := []h.H{
|
||||
h.Class("snake-board"),
|
||||
h.Attr("style", boardStyle),
|
||||
}
|
||||
attrs = append(attrs, rows...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
74
ui/snakelobby.go
Normal file
74
ui/snakelobby.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H {
|
||||
var presetButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(presetClicks) {
|
||||
click = presetClicks[i]
|
||||
}
|
||||
presetButtons = append(presetButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-primary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
createSection := h.Div(h.Class("mb-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Game")),
|
||||
h.Div(h.Class("mb-4"),
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("snake-nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
|
||||
)
|
||||
|
||||
var gameListEl h.H
|
||||
if len(activeGames) > 0 {
|
||||
var items []h.H
|
||||
for _, g := range activeGames {
|
||||
playerCount := g.PlayerCount()
|
||||
sizeLabel := fmt.Sprintf("%d×%d", g.State.Width, g.State.Height)
|
||||
statusLabel := "Waiting"
|
||||
if g.Status == snake.StatusCountdown {
|
||||
statusLabel = "Starting soon"
|
||||
}
|
||||
items = append(items, h.A(
|
||||
h.Href("/snake/"+g.ID),
|
||||
h.Class("flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"),
|
||||
h.Span(h.Text(fmt.Sprintf("%s — %d/8 players", sizeLabel, playerCount))),
|
||||
h.Span(h.Class("text-sm opacity-60"), h.Text(statusLabel)),
|
||||
))
|
||||
}
|
||||
listAttrs := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
gameListEl = h.Div(h.Class("mt-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2 text-center"), h.Text("Join a Game")),
|
||||
h.Div(listAttrs...),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
createSection,
|
||||
gameListEl,
|
||||
)
|
||||
}
|
||||
146
ui/snakestatus.go
Normal file
146
ui/snakestatus.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Waiting for players..."),
|
||||
)
|
||||
|
||||
case snake.StatusCountdown:
|
||||
remaining := time.Until(sg.CountdownEnd)
|
||||
secs := int(math.Ceil(remaining.Seconds()))
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
return h.Div(h.Class("alert alert-info text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Starting in %d...", secs)),
|
||||
)
|
||||
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[mySlot]
|
||||
if s != nil && !s.Alive {
|
||||
return h.Div(h.Class("alert alert-error text-xl font-bold"),
|
||||
h.Text("You're out!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text("Go!"),
|
||||
)
|
||||
|
||||
case snake.StatusFinished:
|
||||
var msg string
|
||||
var class string
|
||||
if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
msg = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
msg = sg.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
} else {
|
||||
msg = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{h.Class(class), h.Text(msg)}
|
||||
|
||||
if sg.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/snake/"+*sg.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if rematchClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
rematchClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
|
||||
var items []h.H
|
||||
|
||||
for i, p := range sg.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
colorHex := "#666"
|
||||
if i < len(snake.SnakeColors) {
|
||||
colorHex = snake.SnakeColors[i]
|
||||
}
|
||||
|
||||
name := p.Nickname
|
||||
if i == mySlot {
|
||||
name += " (You)"
|
||||
}
|
||||
|
||||
var statusEl h.H
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[i]
|
||||
if s != nil {
|
||||
if s.Alive {
|
||||
length := len(s.Body)
|
||||
statusEl = h.Span(h.Class("text-sm opacity-60"), h.Text(fmt.Sprintf(" (%d)", length)))
|
||||
} else {
|
||||
statusEl = h.Span(h.Class("text-sm opacity-40"), h.Text(" (dead)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chipStyle := fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", colorHex)
|
||||
|
||||
items = append(items, h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Attr("style", chipStyle)),
|
||||
h.Span(h.Text(name)),
|
||||
statusEl,
|
||||
))
|
||||
}
|
||||
|
||||
listAttrs := []h.H{h.Class("flex flex-wrap gap-4 mb-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
return h.Div(listAttrs...)
|
||||
}
|
||||
|
||||
func SnakeInviteLink(gameID string) h.H {
|
||||
fullURL := baseURL + "/snake/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
h.P(h.Text("Share this link to invite players:")),
|
||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||
h.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user