Update via to v0.4.0 and decouple tick rate from snake speed

Use via.OnKeyDownMap for snake keybindings, replacing the manual
dataExpr/rawDataAttr workaround. Window-scoped key handling removes
the need for tabindex/focus hacks, and WithPreventDefault on arrow
keys prevents page scrolling during gameplay.

Introduce a 60 FPS tick loop with a separate snake movement speed
(7 cells/s) so direction input is polled every frame but game state
only advances at the configured rate.
This commit is contained in:
Ryan Hamamura
2026-02-02 09:18:13 -10:00
parent 7e78664534
commit 038c4b3f22
4 changed files with 28 additions and 70 deletions

74
main.go
View File

@@ -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...)