commit 389fc12bf226be48090cba6677a213ac2023aab5 Author: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed Jan 14 12:57:57 2026 -1000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a103f67 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +c4 diff --git a/game/logic.go b/game/logic.go new file mode 100644 index 0000000..e8429cd --- /dev/null +++ b/game/logic.go @@ -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 +} diff --git a/game/store.go b/game/store.go new file mode 100644 index 0000000..b44facc --- /dev/null +++ b/game/store.go @@ -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) +} diff --git a/game/types.go b/game/types.go new file mode 100644 index 0000000..38fd6a4 --- /dev/null +++ b/game/types.go @@ -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(), + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab8f217 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7e5d68 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..625ef44 --- /dev/null +++ b/main.go @@ -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; + } +` diff --git a/ui/board.go b/ui/board.go new file mode 100644 index 0000000..316e72b --- /dev/null +++ b/ui/board.go @@ -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)) +} diff --git a/ui/lobby.go b/ui/lobby.go new file mode 100644 index 0000000..2f92588 --- /dev/null +++ b/ui/lobby.go @@ -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, + ), + ), + ), + ) +} diff --git a/ui/status.go b/ui/status.go new file mode 100644 index 0000000..707e336 --- /dev/null +++ b/ui/status.go @@ -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+"')"), + ), + ) +}