Add web host: wasm bridge, page shell, pixel GAME OVER, Playwright smoke tests
This commit is contained in:
13 files changed
+923
-7
No files matched your search
+210
@@ -0,0 +1,210 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Command web runs the gallery in a browser canvas.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/gallery"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// dot is DIPs per dot; each dot is one pixel.
|
||||
const dot = gallery.Dot
|
||||
|
||||
type host struct {
|
||||
app *gallery.App
|
||||
tt *ui.Tester
|
||||
w, h int
|
||||
frames int
|
||||
times []float64
|
||||
opened int
|
||||
over bool
|
||||
dead bool
|
||||
began time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
h := &host{app: gallery.New("/")}
|
||||
api := js.Global().Get("Object").New()
|
||||
export := func(name string, fn func(args []js.Value) any) {
|
||||
api.Set(name, js.FuncOf(func(_ js.Value, args []js.Value) any { return h.guard(name, args, fn) }))
|
||||
}
|
||||
export("start", h.start)
|
||||
export("resize", func(a []js.Value) any { h.size(a[0].Int(), a[1].Int()); return h.out() })
|
||||
export("pointer", h.pointer)
|
||||
export("wheel", func(a []js.Value) any {
|
||||
h.tt.Scroll(dip(a[0]), dip(a[1]), dip(a[2]), dip(a[3]))
|
||||
return h.out()
|
||||
})
|
||||
export("key", h.key)
|
||||
export("text", func(a []js.Value) any { h.tt.Type(a[0].String()); return h.out() })
|
||||
export("compose", func(a []js.Value) any { h.tt.Compose(a[0].String(), a[1].Int()); return h.out() })
|
||||
export("paste", func(a []js.Value) any {
|
||||
h.tt.SetClipboard(a[0].String())
|
||||
h.tt.Command("paste")
|
||||
h.tt.SetClipboard("")
|
||||
return h.out()
|
||||
})
|
||||
export("focus", func(a []js.Value) any { h.tt.SetFocused(a[0].Bool()); return h.out() })
|
||||
export("go", func(a []js.Value) any { h.app.Go(a[0].String()); h.tt.Frame(); return h.out() })
|
||||
export("frame", func([]js.Value) any { h.tt.Frame(); return h.out() })
|
||||
export("stats", h.stats)
|
||||
export("texts", func([]js.Value) any { return toJS(h.tt.Texts()) })
|
||||
export("find", func(a []js.Value) any {
|
||||
r, ok := h.tt.Find(a[0].String())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []any{float64(r.X / dot), float64(r.Y / dot), float64(r.W / dot), float64(r.H / dot)}
|
||||
})
|
||||
js.Global().Set("__8bitGo", api)
|
||||
slog.Info("8bit: wasm ready")
|
||||
select {}
|
||||
}
|
||||
|
||||
// guard runs a JS call; a panic shows GAME OVER.
|
||||
func (h *host) guard(name string, args []js.Value, fn func([]js.Value) any) (res any) {
|
||||
if h.dead {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("8bit: crash", "call", name, "panic", r, "stack", string(debug.Stack()))
|
||||
res = h.crash(fmt.Sprint(r))
|
||||
}
|
||||
}()
|
||||
// An empty clipboard after a call means nothing was copied.
|
||||
if h.tt != nil {
|
||||
h.tt.SetClipboard("")
|
||||
}
|
||||
pixel.ResetDue()
|
||||
h.began = time.Now()
|
||||
return fn(args)
|
||||
}
|
||||
|
||||
// crash shows GAME OVER; a second panic ends the page.
|
||||
func (h *host) crash(why string) (res any) {
|
||||
if h.over || h.w == 0 {
|
||||
h.dead = true
|
||||
js.Global().Call("__8bitCrash", why)
|
||||
return nil
|
||||
}
|
||||
h.over = true
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
res = h.crash(fmt.Sprintf("%s\n\nGAME OVER failed: %v", why, r))
|
||||
}
|
||||
}()
|
||||
h.tt = ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.WinterNight, Dot: dot})
|
||||
ui.Column(c).Fill().Center().Children(func() {
|
||||
if gallery.GameOver(c, why) {
|
||||
js.Global().Get("location").Call("reload")
|
||||
}
|
||||
})
|
||||
}, h.w*dot, h.h*dot)
|
||||
h.tt.SetScale(1.0 / dot)
|
||||
return h.out()
|
||||
}
|
||||
|
||||
// start sizes the window and opens any deep link.
|
||||
func (h *host) start(a []js.Value) any {
|
||||
h.w, h.h = a[0].Int(), a[1].Int()
|
||||
if path := a[2].String(); path != "" {
|
||||
h.app.Go(path)
|
||||
}
|
||||
h.tt = ui.NewTester(h.app.View, h.w*dot, h.h*dot)
|
||||
h.tt.SetScale(1.0 / dot)
|
||||
slog.Info("8bit: start", "dots", fmt.Sprintf("%dx%d", h.w, h.h), "path", h.app.Path())
|
||||
return h.out()
|
||||
}
|
||||
|
||||
func (h *host) size(w, ht int) {
|
||||
h.w, h.h = w, ht
|
||||
h.tt.SetSize(w*dot, ht*dot)
|
||||
slog.Info("8bit: resize", "dots", fmt.Sprintf("%dx%d", w, ht))
|
||||
}
|
||||
|
||||
func (h *host) pointer(a []js.Value) any {
|
||||
x, y := dip(a[1]), dip(a[2])
|
||||
switch kind := a[0].String(); kind {
|
||||
case "move":
|
||||
h.tt.Move(x, y)
|
||||
case "down":
|
||||
h.tt.Press(x, y)
|
||||
case "up":
|
||||
h.tt.Release(x, y)
|
||||
case "right":
|
||||
h.tt.RightClickAt(x, y)
|
||||
default:
|
||||
panic("web: unknown pointer kind " + kind)
|
||||
}
|
||||
return h.out()
|
||||
}
|
||||
|
||||
func (h *host) key(a []js.Value) any {
|
||||
k, ok := keys[a[0].String()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
h.tt.Key(ui.Modifiers(a[1].Int()), k)
|
||||
return h.out()
|
||||
}
|
||||
|
||||
// out returns the frame and requests for the page.
|
||||
func (h *host) out() any {
|
||||
img := h.tt.Image()
|
||||
h.frames++
|
||||
h.times = append(h.times, float64(time.Since(h.began).Microseconds())/1000)
|
||||
if len(h.times) > 240 {
|
||||
h.times = h.times[1:]
|
||||
}
|
||||
pix := js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
|
||||
js.CopyBytesToJS(pix, img.Pix)
|
||||
res := map[string]any{"w": img.Rect.Dx(), "h": img.Rect.Dy(), "pix": pix, "path": h.app.Path(), "cursor": int(h.tt.Cursor())}
|
||||
if due := pixel.Due(); !due.IsZero() {
|
||||
res["due"] = float64(time.Until(due).Milliseconds())
|
||||
}
|
||||
if r, ok := h.tt.TextCaret(); ok {
|
||||
res["caret"] = []any{float64(r.X / dot), float64(r.Y / dot), float64(r.H / dot)}
|
||||
}
|
||||
if c := h.tt.Clipboard(); c != "" {
|
||||
res["copied"] = c
|
||||
}
|
||||
if urls := h.tt.OpenedURLs(); len(urls) > h.opened {
|
||||
res["open"] = toJS(urls[h.opened:])
|
||||
h.opened = len(urls)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (h *host) stats([]js.Value) any {
|
||||
s := slices.Clone(h.times)
|
||||
slices.Sort(s)
|
||||
pct := func(p float64) float64 {
|
||||
if len(s) == 0 {
|
||||
return 0
|
||||
}
|
||||
return s[int(p*float64(len(s)-1))]
|
||||
}
|
||||
return map[string]any{"frames": h.frames, "p50": pct(0.5), "p95": pct(0.95), "w": h.w, "h": h.h, "path": h.app.Path()}
|
||||
}
|
||||
|
||||
func dip(v js.Value) float32 { return float32(v.Float() * dot) }
|
||||
|
||||
func toJS(s []string) any {
|
||||
out := make([]any, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user