diff --git a/go.mod b/go.mod index ec3a0e1..78aacae 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,9 @@ go 1.25.4 require ( github.com/google/uuid v1.6.0 github.com/pressly/goose/v3 v3.26.0 - github.com/ryanhamamura/via v0.3.1 + github.com/ryanhamamura/via v0.4.0 golang.org/x/crypto v0.47.0 + maragu.dev/gomponents v1.2.0 modernc.org/sqlite v1.44.0 ) @@ -42,7 +43,6 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - maragu.dev/gomponents v1.2.0 // indirect modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index f0139b2..6884081 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/ryanhamamura/via v0.3.1 h1:op/t/0YzpPhA68+FOyazMpdZVOCA6S7DGz0eWhXXQAA= -github.com/ryanhamamura/via v0.3.1/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A= +github.com/ryanhamamura/via v0.4.0 h1:/8gfjcPhTl+SEYTPF+Guc6qB2vuW+FtNRQv+HpkV2k8= +github.com/ryanhamamura/via v0.4.0/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU= diff --git a/main.go b/main.go index 51d9671..4346e43 100644 --- a/main.go +++ b/main.go @@ -1,19 +1,13 @@ package main import ( - "bytes" "context" "database/sql" _ "embed" - "fmt" - "html" - "io" "log" "net/http" - "strings" "github.com/google/uuid" - g "maragu.dev/gomponents" "github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/db" @@ -33,37 +27,6 @@ var queries *gen.Queries //go:embed assets/css/output.css var daisyUICSS []byte -// dataExpr renders an h.H attribute node and extracts the raw Datastar expression. -// h.Data/h.Attr HTML-escape values, so we unescape to get the original expression. -func dataExpr(node h.H) string { - var buf bytes.Buffer - node.Render(&buf) - s := buf.String() - start := strings.Index(s, `="`) + 2 - end := strings.LastIndex(s, `"`) - if start < 2 || end <= start { - return "" - } - return html.UnescapeString(s[start:end]) -} - -// rawDataAttr outputs an unescaped data attribute. Needed because gomponents -// HTML-escapes attribute values, which double-escapes expressions extracted -// from rendered nodes. Implements gomponents' attribute interface so it -// renders in the element's opening tag rather than as a child. -type rawDataAttr struct { - name, value string -} - -func (a rawDataAttr) Render(w io.Writer) error { - _, err := fmt.Fprintf(w, ` %s="%s"`, a.name, a.value) - return err -} - -func (a rawDataAttr) Type() g.NodeType { - return g.AttributeType -} - func DaisyUIPlugin(v *via.V) { v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") @@ -627,35 +590,18 @@ func main() { content = append(content, ui.SnakeInviteLink(sg.ID)) } - // Build keydown attributes with unique __suffix names so the - // browser doesn't deduplicate them (all share data-on:keydown). - type keyBinding struct { - suffix string - key string - dir snake.Direction - } - bindings := []keyBinding{ - {"arrowup", "ArrowUp", snake.DirUp}, - {"arrowdown", "ArrowDown", snake.DirDown}, - {"arrowleft", "ArrowLeft", snake.DirLeft}, - {"arrowright", "ArrowRight", snake.DirRight}, - {"w", "w", snake.DirUp}, - {"s", "s", snake.DirDown}, - {"a", "a", snake.DirLeft}, - {"d", "d", snake.DirRight}, - } - wrapperAttrs := []h.H{ h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"), - h.Attr("tabindex", "0"), - h.Data("on:load", "this.focus()"), - } - for _, kb := range bindings { - expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir)))) - wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{ - name: "data-on:keydown__" + kb.suffix, - value: expr, - })) + via.OnKeyDownMap( + via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))), + via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))), + via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))), + via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))), + via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()), + via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()), + via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault()), + via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault()), + ), } wrapperAttrs = append(wrapperAttrs, content...) diff --git a/snake/loop.go b/snake/loop.go index 77aafed..208fdc8 100644 --- a/snake/loop.go +++ b/snake/loop.go @@ -5,7 +5,10 @@ import ( ) const ( - tickInterval = 500 * time.Millisecond + targetFPS = 60 + tickInterval = time.Second / targetFPS + snakeSpeed = 7 // cells per second + moveInterval = time.Second / snakeSpeed countdownSeconds = 10 inactivityLimit = 60 * time.Second ) @@ -91,6 +94,7 @@ func (si *SnakeGameInstance) gamePhase() { defer ticker.Stop() lastInput := time.Now() + var moveAccum time.Duration for { select { @@ -104,7 +108,7 @@ func (si *SnakeGameInstance) gamePhase() { return } - // Apply pending directions (iterate all 8 slots) + // Apply pending directions every tick for responsive input inputReceived := false for i := 0; i < 8; i++ { if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil { @@ -128,6 +132,14 @@ func (si *SnakeGameInstance) gamePhase() { return } + // Only advance game state at snakeSpeed + moveAccum += tickInterval + if moveAccum < moveInterval { + si.gameMu.Unlock() + continue + } + moveAccum -= moveInterval + state := si.game.State // Advance snakes