Add Connect 4 multiplayer game server
Real-time two-player Connect 4 using Via framework with: - Game creation and invite links - SSE-based live updates for both players - Win detection with animated highlighting - Session-based nickname persistence
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
c4
|
||||||
97
game/logic.go
Normal file
97
game/logic.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
// DropPiece attempts to drop a piece in the given column.
|
||||||
|
// Returns (row placed, success).
|
||||||
|
func (g *Game) DropPiece(col, playerColor int) (int, bool) {
|
||||||
|
if col < 0 || col > 6 {
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
if g.CurrentTurn != playerColor {
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
if g.Status != StatusInProgress {
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find lowest empty row in column
|
||||||
|
for row := 5; row >= 0; row-- {
|
||||||
|
if g.Board[row][col] == 0 {
|
||||||
|
g.Board[row][col] = playerColor
|
||||||
|
return row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, false // Column full
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWin checks if the last move at (row, col) created a win.
|
||||||
|
func (g *Game) CheckWin(row, col int) bool {
|
||||||
|
color := g.Board[row][col]
|
||||||
|
directions := [][2]int{
|
||||||
|
{0, 1}, // Horizontal
|
||||||
|
{1, 0}, // Vertical
|
||||||
|
{1, 1}, // Diagonal down-right
|
||||||
|
{1, -1}, // Diagonal down-left
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range directions {
|
||||||
|
cells := g.countLine(row, col, dir[0], dir[1], color)
|
||||||
|
if len(cells) >= 4 {
|
||||||
|
g.Status = StatusWon
|
||||||
|
g.WinningCells = cells[:4]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) countLine(row, col, dr, dc, color int) [][2]int {
|
||||||
|
cells := [][2]int{{row, col}}
|
||||||
|
|
||||||
|
// Count in positive direction
|
||||||
|
for r, c := row+dr, col+dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r+dr, c+dc {
|
||||||
|
if g.Board[r][c] != color {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cells = append(cells, [2]int{r, c})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count in negative direction
|
||||||
|
for r, c := row-dr, col-dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r-dr, c-dc {
|
||||||
|
if g.Board[r][c] != color {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cells = append(cells, [2]int{r, c})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDraw checks if board is full with no winner.
|
||||||
|
func (g *Game) CheckDraw() bool {
|
||||||
|
for col := 0; col < 7; col++ {
|
||||||
|
if g.Board[0][col] == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Status = StatusDraw
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchTurn alternates the current turn.
|
||||||
|
func (g *Game) SwitchTurn() {
|
||||||
|
if g.CurrentTurn == 1 {
|
||||||
|
g.CurrentTurn = 2
|
||||||
|
} else {
|
||||||
|
g.CurrentTurn = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWinningCell checks if a cell is part of the winning line.
|
||||||
|
func (g *Game) IsWinningCell(row, col int) bool {
|
||||||
|
for _, cell := range g.WinningCells {
|
||||||
|
if cell[0] == row && cell[1] == col {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
192
game/store.go
Normal file
192
game/store.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Syncable interface {
|
||||||
|
Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerSession struct {
|
||||||
|
Player *Player
|
||||||
|
Sync Syncable
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameStore struct {
|
||||||
|
games map[string]*GameInstance
|
||||||
|
gamesMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGameStore() *GameStore {
|
||||||
|
return &GameStore{
|
||||||
|
games: make(map[string]*GameInstance),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *GameStore) Create() *GameInstance {
|
||||||
|
id := generateGameID()
|
||||||
|
gi := NewGameInstance(id)
|
||||||
|
gs.gamesMu.Lock()
|
||||||
|
gs.games[id] = gi
|
||||||
|
gs.gamesMu.Unlock()
|
||||||
|
go gi.run()
|
||||||
|
return gi
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||||
|
gs.gamesMu.RLock()
|
||||||
|
defer gs.gamesMu.RUnlock()
|
||||||
|
gi, ok := gs.games[id]
|
||||||
|
return gi, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateGameID() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameInstance struct {
|
||||||
|
game *Game
|
||||||
|
gameMu sync.RWMutex
|
||||||
|
players map[PlayerID]Syncable
|
||||||
|
playersMu sync.RWMutex
|
||||||
|
join chan *PlayerSession
|
||||||
|
leave chan PlayerID
|
||||||
|
done chan struct{}
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGameInstance(id string) *GameInstance {
|
||||||
|
return &GameInstance{
|
||||||
|
game: NewGame(id),
|
||||||
|
players: make(map[PlayerID]Syncable),
|
||||||
|
join: make(chan *PlayerSession, 5),
|
||||||
|
leave: make(chan PlayerID, 5),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) ID() string {
|
||||||
|
gi.gameMu.RLock()
|
||||||
|
defer gi.gameMu.RUnlock()
|
||||||
|
return gi.game.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||||
|
gi.gameMu.Lock()
|
||||||
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
|
// Assign player to an open slot
|
||||||
|
if gi.game.Players[0] == nil {
|
||||||
|
ps.Player.Color = 1 // Red
|
||||||
|
gi.game.Players[0] = ps.Player
|
||||||
|
} else if gi.game.Players[1] == nil {
|
||||||
|
ps.Player.Color = 2 // Yellow
|
||||||
|
gi.game.Players[1] = ps.Player
|
||||||
|
gi.game.Status = StatusInProgress
|
||||||
|
} else {
|
||||||
|
return false // Game is full
|
||||||
|
}
|
||||||
|
|
||||||
|
gi.playersMu.Lock()
|
||||||
|
gi.players[ps.Player.ID] = ps.Sync
|
||||||
|
gi.playersMu.Unlock()
|
||||||
|
|
||||||
|
gi.dirty = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) Leave(pid PlayerID) {
|
||||||
|
gi.leave <- pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) GetGame() *Game {
|
||||||
|
gi.gameMu.RLock()
|
||||||
|
defer gi.gameMu.RUnlock()
|
||||||
|
return gi.game
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
||||||
|
gi.gameMu.RLock()
|
||||||
|
defer gi.gameMu.RUnlock()
|
||||||
|
for _, p := range gi.game.Players {
|
||||||
|
if p != nil && p.ID == pid {
|
||||||
|
return p.Color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||||
|
gi.gameMu.Lock()
|
||||||
|
defer gi.gameMu.Unlock()
|
||||||
|
|
||||||
|
row, ok := gi.game.DropPiece(col, playerColor)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if gi.game.CheckWin(row, col) {
|
||||||
|
for _, p := range gi.game.Players {
|
||||||
|
if p != nil && p.Color == playerColor {
|
||||||
|
gi.game.Winner = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if gi.game.CheckDraw() {
|
||||||
|
// Status already set by CheckDraw
|
||||||
|
} else {
|
||||||
|
gi.game.SwitchTurn()
|
||||||
|
}
|
||||||
|
|
||||||
|
gi.dirty = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) run() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case pid := <-gi.leave:
|
||||||
|
gi.playersMu.Lock()
|
||||||
|
delete(gi.players, pid)
|
||||||
|
gi.playersMu.Unlock()
|
||||||
|
case <-ticker.C:
|
||||||
|
gi.publish()
|
||||||
|
case <-gi.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) publish() {
|
||||||
|
gi.gameMu.Lock()
|
||||||
|
if !gi.dirty {
|
||||||
|
gi.gameMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gi.dirty = false
|
||||||
|
|
||||||
|
gi.playersMu.RLock()
|
||||||
|
syncers := make([]Syncable, 0, len(gi.players))
|
||||||
|
for _, sync := range gi.players {
|
||||||
|
syncers = append(syncers, sync)
|
||||||
|
}
|
||||||
|
gi.playersMu.RUnlock()
|
||||||
|
gi.gameMu.Unlock()
|
||||||
|
|
||||||
|
for _, sync := range syncers {
|
||||||
|
sync.Sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gi *GameInstance) Stop() {
|
||||||
|
close(gi.done)
|
||||||
|
}
|
||||||
41
game/types.go
Normal file
41
game/types.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type PlayerID string
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID PlayerID
|
||||||
|
Nickname string
|
||||||
|
Color int // 1 = Red, 2 = Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusWaitingForPlayer GameStatus = iota
|
||||||
|
StatusInProgress
|
||||||
|
StatusWon
|
||||||
|
StatusDraw
|
||||||
|
)
|
||||||
|
|
||||||
|
type Game struct {
|
||||||
|
ID string
|
||||||
|
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
||||||
|
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
||||||
|
CurrentTurn int // 1 or 2 (matches player color)
|
||||||
|
Status GameStatus
|
||||||
|
Winner *Player
|
||||||
|
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGame(id string) *Game {
|
||||||
|
return &Game{
|
||||||
|
ID: id,
|
||||||
|
Board: [6][7]int{},
|
||||||
|
CurrentTurn: 1, // Red goes first
|
||||||
|
Status: StatusWaitingForPlayer,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module github.com/ryanhamamura/c4
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-via/via-plugin-picocss v0.1.1
|
||||||
|
github.com/ryanhamamura/via v0.2.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||||
|
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/go-via/via v0.1.4 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/starfederation/datastar-go v1.0.3 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
maragu.dev/gomponents v1.2.0 // indirect
|
||||||
|
)
|
||||||
49
go.sum
Normal file
49
go.sum
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
|
||||||
|
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
|
||||||
|
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||||
|
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||||
|
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-via/via v0.1.4 h1:Fz9fwaT5+TBqcetiVM33SxkuysAeFDOiiASFu3GW7WY=
|
||||||
|
github.com/go-via/via v0.1.4/go.mod h1:Y8oddRwP6SWX15Xb6UQj4HtLZwxTYI1HbWBmELtB/f8=
|
||||||
|
github.com/go-via/via-plugin-picocss v0.1.1 h1:rbA9wL9eEanT8HOOfX1b4Mr2L2VjaDrsIrUECDxV73k=
|
||||||
|
github.com/go-via/via-plugin-picocss v0.1.1/go.mod h1:npvsvG2FWeIPkzHzSSzW+uBGE0m5gnIAdlePqKcfuAQ=
|
||||||
|
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||||
|
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||||
|
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/ryanhamamura/via v0.2.3 h1:Fmq2Gws9Ph7njZxYI3O03PhTyfFTOBv5xm+4s053c3E=
|
||||||
|
github.com/ryanhamamura/via v0.2.3/go.mod h1:z1f0pajcta/pD2LEBMVmuBXf/J2yF0obMVKA8FshR9I=
|
||||||
|
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||||
|
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
|
||||||
|
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc=
|
||||||
|
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
|
||||||
318
main.go
Normal file
318
main.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/c4/game"
|
||||||
|
"github.com/ryanhamamura/c4/ui"
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
var store = game.NewGameStore()
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
LogLvl: via.LogLevelDebug,
|
||||||
|
DocumentTitle: "Connect 4",
|
||||||
|
ServerAddress: ":7331",
|
||||||
|
})
|
||||||
|
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
|
h.StyleEl(h.Raw(gameCSS)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Home page - enter nickname and create game
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
nickname := c.Signal("")
|
||||||
|
|
||||||
|
setNickname := c.Action(func() {
|
||||||
|
name := nickname.String()
|
||||||
|
if name != "" {
|
||||||
|
c.Session().Set("nickname", name)
|
||||||
|
c.Sync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createGame := c.Action(func() {
|
||||||
|
name := nickname.String()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Session().Set("nickname", name)
|
||||||
|
|
||||||
|
gi := store.Create()
|
||||||
|
c.Redirectf("/game/%s", gi.ID())
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return ui.LobbyView(
|
||||||
|
nickname.Bind(),
|
||||||
|
setNickname.OnKeyDown("Enter"),
|
||||||
|
createGame.OnClick(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Game page
|
||||||
|
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||||
|
gameID := c.GetPathParam("game_id")
|
||||||
|
sessionNickname := c.Session().GetString("nickname")
|
||||||
|
|
||||||
|
nickname := c.Signal(sessionNickname)
|
||||||
|
colSignal := c.Signal(0)
|
||||||
|
|
||||||
|
var gi *game.GameInstance
|
||||||
|
var player *game.Player
|
||||||
|
var playerJoined bool
|
||||||
|
var gameExists bool
|
||||||
|
|
||||||
|
// Look up game (may not exist during warmup or invalid ID)
|
||||||
|
if gameID != "" {
|
||||||
|
gi, gameExists = store.Get(gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
setNickname := c.Action(func() {
|
||||||
|
if gi == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := nickname.String()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Session().Set("nickname", name)
|
||||||
|
|
||||||
|
if !playerJoined {
|
||||||
|
player = &game.Player{
|
||||||
|
ID: game.PlayerID(generatePlayerID()),
|
||||||
|
Nickname: name,
|
||||||
|
}
|
||||||
|
playerJoined = gi.Join(&game.PlayerSession{
|
||||||
|
Player: player,
|
||||||
|
Sync: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
dropPiece := c.Action(func() {
|
||||||
|
if gi == nil || player == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col := colSignal.Int()
|
||||||
|
gi.DropPiece(col, player.Color)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If nickname exists in session and game exists, join immediately
|
||||||
|
if gameExists && sessionNickname != "" {
|
||||||
|
player = &game.Player{
|
||||||
|
ID: game.PlayerID(generatePlayerID()),
|
||||||
|
Nickname: sessionNickname,
|
||||||
|
}
|
||||||
|
playerJoined = gi.Join(&game.PlayerSession{
|
||||||
|
Player: player,
|
||||||
|
Sync: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
// Game not found - redirect to home
|
||||||
|
if !gameExists {
|
||||||
|
c.Redirect("/")
|
||||||
|
return h.Div()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need nickname first
|
||||||
|
if !playerJoined {
|
||||||
|
return ui.NicknamePrompt(
|
||||||
|
nickname.Bind(),
|
||||||
|
setNickname.OnKeyDown("Enter"),
|
||||||
|
setNickname.OnClick(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := gi.GetGame()
|
||||||
|
myColor := player.Color
|
||||||
|
|
||||||
|
// Create column click function
|
||||||
|
columnClick := func(col int) h.H {
|
||||||
|
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []h.H
|
||||||
|
content = append(content,
|
||||||
|
h.H1(h.Text("Connect 4")),
|
||||||
|
ui.PlayerInfo(g, myColor),
|
||||||
|
ui.StatusBanner(g, myColor),
|
||||||
|
ui.BoardComponent(g, columnClick, myColor),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show invite link when waiting for opponent
|
||||||
|
if g.Status == game.StatusWaitingForPlayer {
|
||||||
|
content = append(content, ui.InviteLink(g.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainAttrs := []h.H{h.Class("container game-container")}
|
||||||
|
mainAttrs = append(mainAttrs, content...)
|
||||||
|
return h.Main(mainAttrs...)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePlayerID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameCSS = `
|
||||||
|
body { margin: 0; }
|
||||||
|
|
||||||
|
.lobby {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: #2563eb;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.clickable:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1e40af;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.red {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.yellow {
|
||||||
|
background: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.winning {
|
||||||
|
animation: pulse 0.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
|
||||||
|
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.waiting {
|
||||||
|
background: var(--pico-muted-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.your-turn {
|
||||||
|
background: #22c55e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.opponent-turn {
|
||||||
|
background: var(--pico-muted-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.winner {
|
||||||
|
background: #22c55e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.loser {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.draw {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip.red {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip.yellow {
|
||||||
|
background: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link {
|
||||||
|
background: var(--pico-muted-background);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
`
|
||||||
60
ui/board.go
Normal file
60
ui/board.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/c4/game"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColumnClickFn returns an h.H onClick attribute for a given column index
|
||||||
|
type ColumnClickFn func(col int) h.H
|
||||||
|
|
||||||
|
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
|
||||||
|
var cols []h.H
|
||||||
|
|
||||||
|
for col := 0; col < 7; col++ {
|
||||||
|
var cells []h.H
|
||||||
|
for row := 0; row < 6; row++ {
|
||||||
|
cellColor := g.Board[row][col]
|
||||||
|
isWinning := g.IsWinningCell(row, col)
|
||||||
|
cells = append(cells, Cell(cellColor, isWinning))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column is clickable only if it's player's turn and game is in progress
|
||||||
|
canClick := g.Status == game.StatusInProgress && g.CurrentTurn == myColor
|
||||||
|
cols = append(cols, Column(col, cells, columnClick, canClick))
|
||||||
|
}
|
||||||
|
|
||||||
|
boardAttrs := []h.H{h.Class("board")}
|
||||||
|
boardAttrs = append(boardAttrs, cols...)
|
||||||
|
return h.Div(boardAttrs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Column(colIdx int, cells []h.H, columnClick ColumnClickFn, canClick bool) h.H {
|
||||||
|
class := "column"
|
||||||
|
if canClick {
|
||||||
|
class += " clickable"
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := []h.H{h.Class(class)}
|
||||||
|
|
||||||
|
if canClick && columnClick != nil {
|
||||||
|
attrs = append(attrs, columnClick(colIdx))
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs = append(attrs, cells...)
|
||||||
|
return h.Div(attrs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Cell(color int, isWinning bool) h.H {
|
||||||
|
class := "cell"
|
||||||
|
switch color {
|
||||||
|
case 1:
|
||||||
|
class += " red"
|
||||||
|
case 2:
|
||||||
|
class += " yellow"
|
||||||
|
}
|
||||||
|
if isWinning {
|
||||||
|
class += " winning"
|
||||||
|
}
|
||||||
|
return h.Div(h.Class(class))
|
||||||
|
}
|
||||||
60
ui/lobby.go
Normal file
60
ui/lobby.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LobbyView(nicknameBind, setNicknameKeyDown, createGameClick h.H) h.H {
|
||||||
|
return h.Main(h.Class("container"),
|
||||||
|
h.Div(h.Class("lobby"),
|
||||||
|
h.H1(h.Text("Connect 4")),
|
||||||
|
h.P(h.Text("Challenge a friend to a game of Connect 4!")),
|
||||||
|
h.Form(
|
||||||
|
h.FieldSet(
|
||||||
|
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||||
|
h.Input(
|
||||||
|
h.ID("nickname"),
|
||||||
|
h.Type("text"),
|
||||||
|
h.Placeholder("Enter your nickname"),
|
||||||
|
nicknameBind,
|
||||||
|
h.Attr("required"),
|
||||||
|
setNicknameKeyDown,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h.Button(
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text("Create Game"),
|
||||||
|
createGameClick,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H {
|
||||||
|
return h.Main(h.Class("container"),
|
||||||
|
h.Div(h.Class("lobby"),
|
||||||
|
h.H1(h.Text("Join Game")),
|
||||||
|
h.P(h.Text("Enter your nickname to join the game.")),
|
||||||
|
h.Form(
|
||||||
|
h.FieldSet(
|
||||||
|
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||||
|
h.Input(
|
||||||
|
h.ID("nickname"),
|
||||||
|
h.Type("text"),
|
||||||
|
h.Placeholder("Enter your nickname"),
|
||||||
|
nicknameBind,
|
||||||
|
h.Attr("required"),
|
||||||
|
h.Attr("autofocus"),
|
||||||
|
setNicknameKeyDown,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h.Button(
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text("Join"),
|
||||||
|
setNicknameClick,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
110
ui/status.go
Normal file
110
ui/status.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/c4/game"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StatusBanner(g *game.Game, myColor int) h.H {
|
||||||
|
var message string
|
||||||
|
var class string
|
||||||
|
|
||||||
|
switch g.Status {
|
||||||
|
case game.StatusWaitingForPlayer:
|
||||||
|
message = "Waiting for opponent..."
|
||||||
|
class = "status waiting"
|
||||||
|
case game.StatusInProgress:
|
||||||
|
if g.CurrentTurn == myColor {
|
||||||
|
message = "Your turn!"
|
||||||
|
class = "status your-turn"
|
||||||
|
} else {
|
||||||
|
opponentName := getOpponentName(g, myColor)
|
||||||
|
message = opponentName + "'s turn"
|
||||||
|
class = "status opponent-turn"
|
||||||
|
}
|
||||||
|
case game.StatusWon:
|
||||||
|
if g.Winner != nil && g.Winner.Color == myColor {
|
||||||
|
message = "You win!"
|
||||||
|
class = "status winner"
|
||||||
|
} else if g.Winner != nil {
|
||||||
|
message = g.Winner.Nickname + " wins!"
|
||||||
|
class = "status loser"
|
||||||
|
}
|
||||||
|
case game.StatusDraw:
|
||||||
|
message = "It's a draw!"
|
||||||
|
class = "status draw"
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.Div(
|
||||||
|
h.Class(class),
|
||||||
|
h.Text(message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOpponentName(g *game.Game, myColor int) string {
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p != nil && p.Color != myColor {
|
||||||
|
return p.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Opponent"
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayerInfo(g *game.Game, myColor int) h.H {
|
||||||
|
var myName, opponentName string
|
||||||
|
var myColorClass, opponentColorClass string
|
||||||
|
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Color == myColor {
|
||||||
|
myName = p.Nickname
|
||||||
|
if p.Color == 1 {
|
||||||
|
myColorClass = "red"
|
||||||
|
} else {
|
||||||
|
myColorClass = "yellow"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
opponentName = p.Nickname
|
||||||
|
if p.Color == 1 {
|
||||||
|
opponentColorClass = "red"
|
||||||
|
} else {
|
||||||
|
opponentColorClass = "yellow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opponentName == "" {
|
||||||
|
opponentName = "Waiting..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.Div(h.Class("player-info"),
|
||||||
|
h.Div(h.Class("player you"),
|
||||||
|
h.Span(h.Class("player-chip "+myColorClass)),
|
||||||
|
h.Span(h.Text(myName+" (You)")),
|
||||||
|
),
|
||||||
|
h.Div(h.Class("player opponent"),
|
||||||
|
h.Span(h.Class("player-chip "+opponentColorClass)),
|
||||||
|
h.Span(h.Text(opponentName)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = "https://demo.adriatica.io"
|
||||||
|
|
||||||
|
func InviteLink(gameID string) h.H {
|
||||||
|
fullURL := baseURL + "/game/" + gameID
|
||||||
|
return h.Div(h.Class("invite-section"),
|
||||||
|
h.P(h.Text("Share this link with your opponent:")),
|
||||||
|
h.Div(h.Class("invite-link"),
|
||||||
|
h.Text(fullURL),
|
||||||
|
),
|
||||||
|
h.Button(
|
||||||
|
h.Class("copy-btn"),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text("Copy Link"),
|
||||||
|
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user