314 lines
5.6 KiB
Go
314 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"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("")
|
|
|
|
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(),
|
|
createGame.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 gameExists bool
|
|
|
|
// Look up game (may not exist during warmup or invalid ID)
|
|
if gameID != "" {
|
|
gi, gameExists = store.Get(gameID)
|
|
}
|
|
|
|
// Generate a stable player ID for this session
|
|
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
|
if playerID == "" {
|
|
playerID = game.PlayerID(game.GenerateID(8))
|
|
c.Session().Set("player_id", string(playerID))
|
|
}
|
|
|
|
setNickname := c.Action(func() {
|
|
if gi == nil {
|
|
return
|
|
}
|
|
name := nickname.String()
|
|
if name == "" {
|
|
return
|
|
}
|
|
c.Session().Set("nickname", name)
|
|
|
|
// Try to join if not already in game
|
|
if gi.GetPlayerColor(playerID) == 0 {
|
|
player := &game.Player{
|
|
ID: playerID,
|
|
Nickname: name,
|
|
}
|
|
gi.Join(&game.PlayerSession{
|
|
Player: player,
|
|
Sync: c,
|
|
})
|
|
}
|
|
c.Sync()
|
|
})
|
|
|
|
dropPiece := c.Action(func() {
|
|
if gi == nil {
|
|
return
|
|
}
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
if myColor == 0 {
|
|
return
|
|
}
|
|
col := colSignal.Int()
|
|
gi.DropPiece(col, myColor)
|
|
c.Sync()
|
|
})
|
|
|
|
// If nickname exists in session and game exists, join immediately
|
|
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
|
player := &game.Player{
|
|
ID: playerID,
|
|
Nickname: sessionNickname,
|
|
}
|
|
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()
|
|
}
|
|
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
|
|
// Need nickname first / not joined yet
|
|
if myColor == 0 {
|
|
return ui.NicknamePrompt(
|
|
nickname.Bind(),
|
|
setNickname.OnKeyDown("Enter"),
|
|
setNickname.OnClick(),
|
|
)
|
|
}
|
|
|
|
g := gi.GetGame()
|
|
|
|
// 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()
|
|
}
|
|
|
|
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;
|
|
}
|
|
`
|