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:
4
go.mod
4
go.mod
@@ -5,8 +5,9 @@ go 1.25.4
|
|||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/pressly/goose/v3 v3.26.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
|
golang.org/x/crypto v0.47.0
|
||||||
|
maragu.dev/gomponents v1.2.0
|
||||||
modernc.org/sqlite v1.44.0
|
modernc.org/sqlite v1.44.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +43,6 @@ require (
|
|||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/time v0.14.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/libc v1.67.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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.4.0 h1:/8gfjcPhTl+SEYTPF+Guc6qB2vuW+FtNRQv+HpkV2k8=
|
||||||
github.com/ryanhamamura/via v0.3.1/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A=
|
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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||||
|
|||||||
74
main.go
74
main.go
@@ -1,19 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
g "maragu.dev/gomponents"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/c4/auth"
|
"github.com/ryanhamamura/c4/auth"
|
||||||
"github.com/ryanhamamura/c4/db"
|
"github.com/ryanhamamura/c4/db"
|
||||||
@@ -33,37 +27,6 @@ var queries *gen.Queries
|
|||||||
//go:embed assets/css/output.css
|
//go:embed assets/css/output.css
|
||||||
var daisyUICSS []byte
|
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) {
|
func DaisyUIPlugin(v *via.V) {
|
||||||
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/css")
|
w.Header().Set("Content-Type", "text/css")
|
||||||
@@ -627,35 +590,18 @@ func main() {
|
|||||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
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{
|
wrapperAttrs := []h.H{
|
||||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||||
h.Attr("tabindex", "0"),
|
via.OnKeyDownMap(
|
||||||
h.Data("on:load", "this.focus()"),
|
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))),
|
||||||
}
|
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))),
|
||||||
for _, kb := range bindings {
|
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))),
|
||||||
expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir))))
|
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))),
|
||||||
wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{
|
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()),
|
||||||
name: "data-on:keydown__" + kb.suffix,
|
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()),
|
||||||
value: expr,
|
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...)
|
wrapperAttrs = append(wrapperAttrs, content...)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tickInterval = 500 * time.Millisecond
|
targetFPS = 60
|
||||||
|
tickInterval = time.Second / targetFPS
|
||||||
|
snakeSpeed = 7 // cells per second
|
||||||
|
moveInterval = time.Second / snakeSpeed
|
||||||
countdownSeconds = 10
|
countdownSeconds = 10
|
||||||
inactivityLimit = 60 * time.Second
|
inactivityLimit = 60 * time.Second
|
||||||
)
|
)
|
||||||
@@ -91,6 +94,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
lastInput := time.Now()
|
lastInput := time.Now()
|
||||||
|
var moveAccum time.Duration
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -104,7 +108,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pending directions (iterate all 8 slots)
|
// Apply pending directions every tick for responsive input
|
||||||
inputReceived := false
|
inputReceived := false
|
||||||
for i := 0; i < 8; i++ {
|
for i := 0; i < 8; i++ {
|
||||||
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only advance game state at snakeSpeed
|
||||||
|
moveAccum += tickInterval
|
||||||
|
if moveAccum < moveInterval {
|
||||||
|
si.gameMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moveAccum -= moveInterval
|
||||||
|
|
||||||
state := si.game.State
|
state := si.game.State
|
||||||
|
|
||||||
// Advance snakes
|
// Advance snakes
|
||||||
|
|||||||
Reference in New Issue
Block a user