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:
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;
|
||||
}
|
||||
`
|
||||
Reference in New Issue
Block a user