169 lines
4.2 KiB
Go
169 lines
4.2 KiB
Go
|
|
package game
|
||
|
|
|
||
|
|
import (
|
||
|
|
"math/rand"
|
||
|
|
"sync"
|
||
|
|
|
||
|
|
tea "github.com/charmbracelet/bubbletea"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
Width = 76
|
||
|
|
Height = 20
|
||
|
|
)
|
||
|
|
|
||
|
|
type Cell struct {
|
||
|
|
Char rune
|
||
|
|
Color string // lipgloss-compatible ANSI color string, "" = unset
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cursor is another connected session's position, shown as a marker on
|
||
|
|
// top of the canvas so drawing feels like a shared space, not a diff you
|
||
|
|
// only notice after the fact.
|
||
|
|
type Cursor struct {
|
||
|
|
X, Y int
|
||
|
|
Color string
|
||
|
|
Label string
|
||
|
|
}
|
||
|
|
|
||
|
|
// canvasUpdatedMsg is broadcast to every connected session's bubbletea
|
||
|
|
// program whenever the shared state changes — cells or cursors — so each
|
||
|
|
// client's own Update/View loop redraws without polling.
|
||
|
|
type canvasUpdatedMsg struct{}
|
||
|
|
|
||
|
|
var cursorPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#00d2d3"}
|
||
|
|
|
||
|
|
// Canvas is the single shared drawing surface plus the registry of
|
||
|
|
// connected sessions' bubbletea programs, used purely to fan a redraw
|
||
|
|
// signal out to everyone whenever anything changes -- there's no other
|
||
|
|
// use of the registry (no per-session logic reaches back in through it).
|
||
|
|
type Canvas struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
cells [Height][Width]Cell
|
||
|
|
cursors map[string]*Cursor
|
||
|
|
programs map[string]*tea.Program
|
||
|
|
nextColorIdx int
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewCanvas() *Canvas {
|
||
|
|
return &Canvas{
|
||
|
|
cursors: make(map[string]*Cursor),
|
||
|
|
programs: make(map[string]*tea.Program),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) Join(id string, label string, p *tea.Program) *Cursor {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
color := cursorPalette[c.nextColorIdx%len(cursorPalette)]
|
||
|
|
c.nextColorIdx++
|
||
|
|
cur := &Cursor{X: Width / 2, Y: Height / 2, Color: color, Label: label}
|
||
|
|
c.cursors[id] = cur
|
||
|
|
c.programs[id] = p
|
||
|
|
c.broadcastLocked()
|
||
|
|
return cur
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) Leave(id string) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
delete(c.cursors, id)
|
||
|
|
delete(c.programs, id)
|
||
|
|
c.broadcastLocked()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) Paint(x, y int, char rune, color string) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
if x < 0 || x >= Width || y < 0 || y >= Height {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.cells[y][x] = Cell{Char: char, Color: color}
|
||
|
|
c.broadcastLocked()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) Clear() {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
c.cells = [Height][Width]Cell{}
|
||
|
|
c.broadcastLocked()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) MoveCursor(id string, dx, dy int) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
cur, ok := c.cursors[id]
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
cur.X = clamp(cur.X+dx, 0, Width-1)
|
||
|
|
cur.Y = clamp(cur.Y+dy, 0, Height-1)
|
||
|
|
c.broadcastLocked()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Snapshot returns a copy of the current cells and cursors, safe to read
|
||
|
|
// from a View() call without holding the lock while rendering.
|
||
|
|
func (c *Canvas) Snapshot() (cells [Height][Width]Cell, cursors map[string]Cursor) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
cursors = make(map[string]Cursor, len(c.cursors))
|
||
|
|
for id, cur := range c.cursors {
|
||
|
|
cursors[id] = *cur
|
||
|
|
}
|
||
|
|
return c.cells, cursors
|
||
|
|
}
|
||
|
|
|
||
|
|
// CursorsSnapshot is Snapshot() without paying for a cell-array copy when
|
||
|
|
// only cursor positions are needed (the common case for input handling).
|
||
|
|
func (c *Canvas) CursorsSnapshot() map[string]Cursor {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
cursors := make(map[string]Cursor, len(c.cursors))
|
||
|
|
for id, cur := range c.cursors {
|
||
|
|
cursors[id] = *cur
|
||
|
|
}
|
||
|
|
return cursors
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Canvas) PeerCount() int {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
return len(c.cursors)
|
||
|
|
}
|
||
|
|
|
||
|
|
// broadcastLocked must be called with c.mu already held. Each Send runs in
|
||
|
|
// its own goroutine rather than inline: a just-joined program's Run()
|
||
|
|
// loop hasn't started reading its input channel yet at the moment Join()
|
||
|
|
// calls this (Join registers the program, then broadcasts, before the
|
||
|
|
// caller gets around to calling p.Run()) -- an inline, blocking Send to
|
||
|
|
// that program would deadlock the whole session handler before it ever
|
||
|
|
// reaches Run(). Confirmed by hitting exactly that deadlock in testing:
|
||
|
|
// the very first session hung with nothing rendered.
|
||
|
|
func (c *Canvas) broadcastLocked() {
|
||
|
|
for _, p := range c.programs {
|
||
|
|
if p == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
go p.Send(canvasUpdatedMsg{})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func clamp(v, lo, hi int) int {
|
||
|
|
if v < lo {
|
||
|
|
return lo
|
||
|
|
}
|
||
|
|
if v > hi {
|
||
|
|
return hi
|
||
|
|
}
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
|
||
|
|
func RandomID() string {
|
||
|
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
|
|
b := make([]byte, 8)
|
||
|
|
for i := range b {
|
||
|
|
b[i] = chars[rand.Intn(len(chars))]
|
||
|
|
}
|
||
|
|
return string(b)
|
||
|
|
}
|