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:
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