Files
games/main.go
Ryan Hamamura 32e4e61635 Auto-generate nicknames for invitees joining via link
Invitees no longer need to enter a nickname - they automatically
join with a random name like "Swift Tiger" or "Happy Falcon".
Game creators still enter their nickname manually.
2026-01-14 14:45:04 -10:00

333 lines
6.0 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()
})
// Auto-join logic
if gameExists && gi.GetPlayerColor(playerID) == 0 {
g := gi.GetGame()
// Invitee: game is waiting for second player - auto-generate nickname and join
if g.Status == game.StatusWaitingForPlayer {
name := sessionNickname
if name == "" {
name = game.GenerateNickname()
c.Session().Set("nickname", name)
}
player := &game.Player{
ID: playerID,
Nickname: name,
}
gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
} else if sessionNickname != "" {
// Reconnecting player with existing nickname
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;
}
`