Files
aego/platform/web/web.go
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

497 lines
11 KiB
Go

//go:build js
package web
import (
"bytes"
"math"
"strconv"
"strings"
"syscall/js"
"aego/core/errs"
"aego/core/mathx"
"aego/platform"
"aego/platform/input"
)
const (
MimeText = "text/plain"
CanvasAttr = "data-aego"
contextName = "webgl2"
)
type Options struct {
Canvas js.Value
Selector string
}
type listener struct {
target js.Value
name string
fn js.Func
}
type Platform struct {
opt Options
global js.Value
doc js.Value
nav js.Value
raf js.Value
now js.Value
gamepads js.Value
vpad js.Value
info platform.Info
win *Window
queue []platform.Event
spare []platform.Event
listeners []listener
noop js.Func
start float64
hiddenAt float64
hiddenFor float64
hidden bool
resetFrameClock bool
padLive int
pads [input.MaxPads]padSlot
pad padFrame
interval float64
next float64
}
func try(v js.Value, name string, args ...any) js.Value {
if !v.Truthy() || v.Get(name).Type() != js.TypeFunction {
return js.Undefined()
}
return v.Call(name, args...)
}
func New(opt Options) (*Platform, error) {
g := js.Global()
doc := g.Get("document")
if !doc.Truthy() {
return nil, errs.New(errs.PlatformInit, "the web platform needs a browser document")
}
perf := g.Get("performance")
nav := g.Get("navigator")
p := &Platform{
opt: opt,
global: g,
doc: doc,
nav: nav,
raf: g.Get("requestAnimationFrame").Call("bind", g),
now: perf.Get("now").Call("bind", perf),
queue: make([]platform.Event, 0, 64),
spare: make([]platform.Event, 0, 64),
}
if f := nav.Get("getGamepads"); f.Type() == js.TypeFunction {
p.gamepads = f.Call("bind", nav)
}
p.info = platform.Info{OS: "js", Arch: "wasm", Backend: "web", Version: nav.Get("userAgent").String(), VideoDriver: contextName}
p.noop = js.FuncOf(func(js.Value, []js.Value) any { return nil })
p.start = p.ms()
p.hidden = doc.Get("hidden").Truthy()
p.hiddenAt = p.start
p.listen(doc, "visibilitychange", true, p.visibility)
p.listen(g, "gamepadconnected", true, func(js.Value) { p.padLive++ })
p.listen(g, "gamepaddisconnected", true, func(js.Value) { p.padLive = max(0, p.padLive-1) })
p.listen(g, "blur", true, func(js.Value) { p.push(platform.Event{Kind: platform.EvWindowFocusLost}) })
return p, nil
}
func (p *Platform) ms() float64 {
return p.now.Invoke().Float()
}
func (p *Platform) Now() float64 {
t := p.ms()
if p.hidden {
t = p.hiddenAt
}
return (t - p.start - p.hiddenFor) / 1000
}
func (p *Platform) visibility(js.Value) {
hidden := p.doc.Get("hidden").Truthy()
if hidden == p.hidden {
return
}
t := p.ms()
if hidden {
p.hiddenAt = t
p.push(platform.Event{Kind: platform.EvWindowFocusLost})
} else {
p.hiddenFor += t - p.hiddenAt
// Do not charge an incomplete foreground frame to the next simulation tick.
p.resetFrameClock = true
}
p.hidden = hidden
}
func (p *Platform) ConsumeFrameClockReset() bool {
pending := p.resetFrameClock
p.resetFrameClock = false
return pending
}
func (p *Platform) quiet(v js.Value) {
if v.Truthy() && v.Get("catch").Type() == js.TypeFunction {
v.Call("catch", p.noop)
}
}
func (p *Platform) writeText(s string) {
p.quiet(try(p.nav.Get("clipboard"), "writeText", s))
}
func (p *Platform) canvas() js.Value {
c := p.opt.Canvas
if !c.Truthy() && p.opt.Selector != "" {
c = p.doc.Call("querySelector", p.opt.Selector)
}
if !c.Truthy() {
c = p.doc.Call("querySelector", "canvas["+CanvasAttr+"]")
}
if !c.Truthy() {
c = p.doc.Call("createElement", "canvas")
c.Call("setAttribute", CanvasAttr, "")
p.doc.Get("body").Call("appendChild", c)
}
return c
}
func (p *Platform) CreateWindow(desc platform.WindowDesc) (platform.Window, error) {
if p.win != nil {
return nil, errs.New(errs.PlatformWindow, "only one window is supported")
}
c := p.canvas()
ctx := c.Call("getContext", contextName, map[string]any{
"alpha": false, "antialias": false, "depth": false, "stencil": false,
"premultipliedAlpha": false, "preserveDrawingBuffer": false, "powerPreference": "high-performance",
})
if !ctx.Truthy() {
return nil, errs.New(errs.PlatformContext, "this browser has no WebGL2")
}
w := &Window{p: p, canvas: c, style: c.Get("style"), ctx: ctx, cursor: 255}
c.Set("tabIndex", 0)
w.style.Set("touchAction", "none")
w.style.Set("outline", "none")
if r := c.Call("getBoundingClientRect"); r.Get("width").Float() == 0 || r.Get("height").Float() == 0 {
w.style.Set("width", strconv.Itoa(max(desc.Width, 1))+"px")
w.style.Set("height", strconv.Itoa(max(desc.Height, 1))+"px")
}
p.win = w
w.refresh(false)
p.listenWindow(w)
w.wantFull = desc.Fullscreen
return w, nil
}
func (p *Platform) PollEvents(fn func(platform.Event)) {
if w := p.win; w != nil && p.global.Get("devicePixelRatio").Float() != w.rawDPR {
w.refresh(true)
}
p.pollPads()
if len(p.queue) == 0 {
return
}
p.spare, p.queue = p.queue, p.spare[:0]
for i := range p.spare {
fn(p.spare[i])
}
p.spare = p.spare[:0]
}
const frameSlack = 2
func (p *Platform) SetFrameLimit(fps float64) {
p.interval, p.next = 0, 0
if fps > 0 {
p.interval = 1000 / fps
}
}
func (p *Platform) due(args []js.Value) bool {
if p.interval <= 0 || len(args) == 0 {
return true
}
t := args[0].Float()
if t < p.next-frameSlack {
return false
}
p.next += p.interval
if p.next <= t {
p.next = t + p.interval
}
return true
}
func (p *Platform) Drive(step func() bool) {
done := make(chan struct{})
var frame js.Func
frame = js.FuncOf(func(_ js.Value, args []js.Value) any {
// Browsers may still deliver RAF callbacks to a hidden document.
// Keep the callback registered and leave queued input for the visible frame.
if p.hidden || !p.due(args) {
p.raf.Invoke(frame)
return nil
}
if !step() {
frame.Release()
close(done)
return nil
}
p.raf.Invoke(frame)
return nil
})
p.raf.Invoke(frame)
<-done
}
func (p *Platform) Info() platform.Info {
return p.info
}
func (p *Platform) Shutdown() {
if p.win != nil {
p.win.Close()
}
for _, l := range p.listeners {
l.target.Call("removeEventListener", l.name, l.fn)
l.fn.Release()
}
p.listeners = nil
}
type memory struct {
mime string
data []byte
text string
}
type Window struct {
p *Platform
canvas js.Value
style js.Value
ctx js.Value
observer js.Value
resize js.Func
observing bool
dark js.Value
metrics platform.WindowMetrics
dpr float64
rawDPR float64
maxDPR float64
last mathx.Vec2
textInput bool
cursor platform.CursorShape
wantLock bool
locked bool
wantFull bool
clip memory
closed bool
}
func (w *Window) WebGL2() js.Value {
return w.ctx
}
func (w *Window) Canvas() js.Value {
return w.canvas
}
func (w *Window) SetMaxPixelRatio(r float64) {
if r == w.maxDPR {
return
}
w.maxDPR = r
w.refresh(true)
}
func (w *Window) refresh(push bool) {
r := w.canvas.Call("getBoundingClientRect")
cw, ch := r.Get("width").Float(), r.Get("height").Float()
raw := w.p.global.Get("devicePixelRatio").Float()
if raw <= 0 {
raw = 1
}
w.rawDPR = raw
dpr := raw
if w.maxDPR > 0 {
dpr = min(dpr, w.maxDPR)
}
w.dpr = dpr
m := platform.WindowMetrics{
LogicalW: int(math.Round(cw)),
LogicalH: int(math.Round(ch)),
PixelW: max(1, int(math.Round(cw*dpr))),
PixelH: max(1, int(math.Round(ch*dpr))),
PixelDensity: float32(dpr),
DisplayScale: float32(dpr),
}
if m == w.metrics {
return
}
if w.canvas.Get("width").Int() != m.PixelW {
w.canvas.Set("width", m.PixelW)
}
if w.canvas.Get("height").Int() != m.PixelH {
w.canvas.Set("height", m.PixelH)
}
w.metrics = m
if push {
w.p.push(platform.Event{Kind: platform.EvWindowResized})
}
}
func (w *Window) pos(e js.Value) mathx.Vec2 {
return mathx.Vec2{X: float32(e.Get("offsetX").Float()), Y: float32(e.Get("offsetY").Float())}
}
func (w *Window) gesture() {
if w.wantLock && !w.locked {
w.p.quiet(try(w.canvas, "requestPointerLock"))
}
if w.wantFull {
w.wantFull = false
if !w.p.doc.Get("fullscreenElement").Truthy() {
w.p.quiet(try(w.canvas, "requestFullscreen"))
}
}
}
func (w *Window) Metrics() platform.WindowMetrics {
return w.metrics
}
func (w *Window) SetTitle(s string) {
w.p.doc.Set("title", s)
}
func (w *Window) SetVSync(platform.VSyncMode) platform.VSyncMode {
return platform.VSyncOn
}
func (w *Window) SetFullscreen(on bool) {
w.wantFull = on
if !on && w.p.doc.Get("fullscreenElement").Truthy() {
w.p.quiet(try(w.p.doc, "exitFullscreen"))
}
}
func (w *Window) SetPointerLock(on bool) {
w.wantLock = on
if !on && w.locked {
try(w.p.doc, "exitPointerLock")
}
}
func (w *Window) PointerLocked() bool {
return w.locked
}
func (w *Window) SwapBuffers() {}
func (w *Window) StartTextInput() {
w.textInput = true
}
func (w *Window) StopTextInput() {
w.textInput = false
}
func (w *Window) SetTextInputArea(mathx.Rect, int) {}
func textMime(m string) bool {
return m == MimeText || strings.HasPrefix(m, MimeText+";")
}
func (w *Window) ClipboardText() string {
return w.clip.text
}
func (w *Window) SetClipboardText(s string) {
w.clip = memory{text: s}
w.p.writeText(s)
}
func (w *Window) SetClipboardData(mime string, data []byte, text string) bool {
w.clip = memory{mime: mime, data: bytes.Clone(data), text: text}
w.p.writeText(text)
return true
}
func (w *Window) ClipboardData(mime string) ([]byte, bool) {
switch {
case mime != "" && mime == w.clip.mime:
return bytes.Clone(w.clip.data), true
case textMime(mime) && w.clip.text != "":
return []byte(w.clip.text), true
}
return nil, false
}
func (w *Window) SetCursor(s platform.CursorShape) {
if s == w.cursor {
return
}
w.cursor = s
w.style.Set("cursor", cursorName(s))
}
func (w *Window) CaptureMouse(bool) {}
func (w *Window) PreferredLocales() []string {
nav := w.p.global.Get("navigator")
if nav.Type() != js.TypeObject {
return nil
}
var out []string
if ls := nav.Get("languages"); ls.Type() == js.TypeObject {
for i := 0; i < ls.Length(); i++ {
out = append(out, ls.Index(i).String())
}
}
if len(out) == 0 {
if l := nav.Get("language"); l.Type() == js.TypeString {
out = append(out, l.String())
}
}
return out
}
func (w *Window) SystemTheme() platform.SystemTheme {
if w.dark.Type() != js.TypeObject {
return platform.ThemeUnknown
}
if w.dark.Get("matches").Truthy() {
return platform.ThemeDark
}
return platform.ThemeLight
}
func (w *Window) Close() {
if w.closed {
return
}
w.closed = true
if w.observing {
w.observer.Call("disconnect")
w.resize.Release()
w.observing = false
}
if w.p.win == w {
w.p.win = nil
}
}
var (
_ platform.Driver = (*Platform)(nil)
_ platform.TextInputer = (*Window)(nil)
_ platform.Clipboard = (*Window)(nil)
_ platform.Cursorer = (*Window)(nil)
_ platform.MouseCapturer = (*Window)(nil)
_ platform.FrameLimiter = (*Platform)(nil)
_ platform.PixelRatioLimiter = (*Window)(nil)
)