From dc2eb1d45b919c35805d0dec848feb6a8f924693 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 21:44:38 -0400 Subject: [PATCH 1/3] Add WebAssembly (js/wasm) backend for the opengl package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel WebGL2 backend under the `js && wasm` build tag so pixel can target the browser without touching desktop code paths. The existing GLFW + go-gl files are tagged `!js`; new `*_wasm.go` siblings implement Window, Canvas, input (keyboard/mouse via DOM events), and stubs for cursor/joystick/monitor. Also refactors a few desktop call sites off the raw `go-gl/gl` package and onto glhf wrappers (`BlendFuncSeparate`, `BlendEquation`, `ActiveTexture`) so the same source compiles for both targets. Two of pixel's internal shaders gained explicit float literals (`0.0`, `2.0`) so GLSL ES 300 — which is stricter about int->float conversion than 330 core — accepts them unchanged. Depends on companion WASM branches of glhf and mainthread. Desktop behavior is unchanged. Co-Authored-By: Claude Opus 4.7 --- backends/opengl/cursor.go | 2 + backends/opengl/cursor_wasm.go | 31 ++++ backends/opengl/glpicture.go | 15 ++ backends/opengl/glshader.go | 10 +- backends/opengl/input.go | 2 + backends/opengl/input_dom_wasm.go | 221 +++++++++++++++++++++++ backends/opengl/input_wasm.go | 51 ++++++ backends/opengl/joystick.go | 2 + backends/opengl/joystick_wasm.go | 17 ++ backends/opengl/monitor.go | 2 + backends/opengl/monitor_wasm.go | 32 ++++ backends/opengl/run.go | 2 + backends/opengl/run_wasm.go | 12 ++ backends/opengl/window.go | 2 + backends/opengl/window_wasm.go | 287 ++++++++++++++++++++++++++++++ 15 files changed, 683 insertions(+), 5 deletions(-) create mode 100644 backends/opengl/cursor_wasm.go create mode 100644 backends/opengl/input_dom_wasm.go create mode 100644 backends/opengl/input_wasm.go create mode 100644 backends/opengl/joystick_wasm.go create mode 100644 backends/opengl/monitor_wasm.go create mode 100644 backends/opengl/run_wasm.go create mode 100644 backends/opengl/window_wasm.go diff --git a/backends/opengl/cursor.go b/backends/opengl/cursor.go index 6dc7580..31802f8 100644 --- a/backends/opengl/cursor.go +++ b/backends/opengl/cursor.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/cursor_wasm.go b/backends/opengl/cursor_wasm.go new file mode 100644 index 0000000..32fd31a --- /dev/null +++ b/backends/opengl/cursor_wasm.go @@ -0,0 +1,31 @@ +//go:build js && wasm + +package opengl + +import ( + "image" + + "github.com/gopxl/pixel/v2" +) + +// StandardCursor matches the desktop-side typed constant. Under WASM we have +// no real cursor API wired up, so the constants exist only so game code can +// reference them unchanged. +type StandardCursor int + +const ( + ArrowCursor StandardCursor = iota + IBeamCursor + CrosshairCursor + HandCursor + HResizeCursor + VResizeCursor +) + +// Cursor is an opaque handle. We keep the struct empty; the browser manages +// the mouse cursor itself. +type Cursor struct{} + +func CreateStandardCursor(StandardCursor) *Cursor { return &Cursor{} } +func CreateCursorImage(image.Image, pixel.Vec) *Cursor { return &Cursor{} } +func (w *Window) SetCursor(*Cursor) {} diff --git a/backends/opengl/glpicture.go b/backends/opengl/glpicture.go index 3f16504..b31dfad 100644 --- a/backends/opengl/glpicture.go +++ b/backends/opengl/glpicture.go @@ -2,6 +2,7 @@ package opengl import ( "math" + "sync" "github.com/gopxl/glhf/v2" "github.com/gopxl/mainthread/v2" @@ -18,9 +19,20 @@ type GLPicture interface { Texture() *glhf.Texture } +// pictureDataGLCache memoizes GLPicture uploads keyed by source *pixel.PictureData. +// Without this, every (sprite, canvas) pair re-uploads and retains a full-size pixel +// copy; on 32-bit WASM (~4GB heap) a 4096x4096 atlas blows the heap after a handful +// of draw targets. +var pictureDataGLCache sync.Map // *pixel.PictureData -> GLPicture + // NewGLPicture creates a new GLPicture with it's own static OpenGL texture. This function always // allocates a new texture that cannot (shouldn't) be further modified. func NewGLPicture(p pixel.Picture) GLPicture { + if pd, ok := p.(*pixel.PictureData); ok { + if cached, found := pictureDataGLCache.Load(pd); found { + return cached.(GLPicture) + } + } bounds := p.Bounds() bx, by, bw, bh := intBounds(bounds) @@ -65,6 +77,9 @@ func NewGLPicture(p pixel.Picture) GLPicture { tex: tex, pixels: pixels, } + if pd, ok := p.(*pixel.PictureData); ok { + pictureDataGLCache.Store(pd, gp) + } return gp } diff --git a/backends/opengl/glshader.go b/backends/opengl/glshader.go index 48b3d0e..27fddc3 100644 --- a/backends/opengl/glshader.go +++ b/backends/opengl/glshader.go @@ -256,7 +256,7 @@ uniform vec4 uBounds; void main() { vec2 transPos = (uTransform * vec3(aPosition, 1.0)).xy; - vec2 normPos = (transPos - uBounds.xy) / uBounds.zw * 2 - vec2(1, 1); + vec2 normPos = (transPos - uBounds.xy) / uBounds.zw * 2.0 - vec2(1.0, 1.0); gl_Position = vec4(normPos, 0.0, 1.0); vColor = aColor; @@ -282,14 +282,14 @@ uniform vec4 uTexBounds; uniform sampler2D uTexture; void main() { - if ((vClipRect != vec4(0,0,0,0)) && (gl_FragCoord.x < vClipRect.x || gl_FragCoord.y < vClipRect.y || gl_FragCoord.x > vClipRect.z || gl_FragCoord.y > vClipRect.w)) + if ((vClipRect != vec4(0.0, 0.0, 0.0, 0.0)) && (gl_FragCoord.x < vClipRect.x || gl_FragCoord.y < vClipRect.y || gl_FragCoord.x > vClipRect.z || gl_FragCoord.y > vClipRect.w)) discard; - if (vIntensity == 0) { + if (vIntensity == 0.0) { fragColor = uColorMask * vColor; } else { - fragColor = vec4(0, 0, 0, 0); - fragColor += (1 - vIntensity) * vColor; + fragColor = vec4(0.0, 0.0, 0.0, 0.0); + fragColor += (1.0 - vIntensity) * vColor; vec2 t = (vTexCoords - uTexBounds.xy) / uTexBounds.zw; fragColor += vIntensity * vColor * texture(uTexture, t); fragColor *= uColorMask; diff --git a/backends/opengl/input.go b/backends/opengl/input.go index dffd95d..8bb2ca5 100644 --- a/backends/opengl/input.go +++ b/backends/opengl/input.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go new file mode 100644 index 0000000..3f62a95 --- /dev/null +++ b/backends/opengl/input_dom_wasm.go @@ -0,0 +1,221 @@ +//go:build js && wasm + +package opengl + +import ( + "strings" + "syscall/js" + + "github.com/gopxl/pixel/v2" +) + +// domCodeToButton maps KeyboardEvent.code values to pixel.Button constants. +// Only keys the game consumes are mapped; unknown codes yield ok=false. +var domCodeToButton = map[string]pixel.Button{ + "Space": pixel.KeySpace, + "Quote": pixel.KeyApostrophe, + "Comma": pixel.KeyComma, + "Minus": pixel.KeyMinus, + "Period": pixel.KeyPeriod, + "Slash": pixel.KeySlash, + "Digit0": pixel.Key0, + "Digit1": pixel.Key1, + "Digit2": pixel.Key2, + "Digit3": pixel.Key3, + "Digit4": pixel.Key4, + "Digit5": pixel.Key5, + "Digit6": pixel.Key6, + "Digit7": pixel.Key7, + "Digit8": pixel.Key8, + "Digit9": pixel.Key9, + "Semicolon": pixel.KeySemicolon, + "Equal": pixel.KeyEqual, + "KeyA": pixel.KeyA, + "KeyB": pixel.KeyB, + "KeyC": pixel.KeyC, + "KeyD": pixel.KeyD, + "KeyE": pixel.KeyE, + "KeyF": pixel.KeyF, + "KeyG": pixel.KeyG, + "KeyH": pixel.KeyH, + "KeyI": pixel.KeyI, + "KeyJ": pixel.KeyJ, + "KeyK": pixel.KeyK, + "KeyL": pixel.KeyL, + "KeyM": pixel.KeyM, + "KeyN": pixel.KeyN, + "KeyO": pixel.KeyO, + "KeyP": pixel.KeyP, + "KeyQ": pixel.KeyQ, + "KeyR": pixel.KeyR, + "KeyS": pixel.KeyS, + "KeyT": pixel.KeyT, + "KeyU": pixel.KeyU, + "KeyV": pixel.KeyV, + "KeyW": pixel.KeyW, + "KeyX": pixel.KeyX, + "KeyY": pixel.KeyY, + "KeyZ": pixel.KeyZ, + "BracketLeft": pixel.KeyLeftBracket, + "Backslash": pixel.KeyBackslash, + "BracketRight": pixel.KeyRightBracket, + "Backquote": pixel.KeyGraveAccent, + "Escape": pixel.KeyEscape, + "Enter": pixel.KeyEnter, + "Tab": pixel.KeyTab, + "Backspace": pixel.KeyBackspace, + "Insert": pixel.KeyInsert, + "Delete": pixel.KeyDelete, + "ArrowRight": pixel.KeyRight, + "ArrowLeft": pixel.KeyLeft, + "ArrowDown": pixel.KeyDown, + "ArrowUp": pixel.KeyUp, + "PageUp": pixel.KeyPageUp, + "PageDown": pixel.KeyPageDown, + "Home": pixel.KeyHome, + "End": pixel.KeyEnd, + "CapsLock": pixel.KeyCapsLock, + "ScrollLock": pixel.KeyScrollLock, + "NumLock": pixel.KeyNumLock, + "PrintScreen": pixel.KeyPrintScreen, + "Pause": pixel.KeyPause, + "F1": pixel.KeyF1, + "F2": pixel.KeyF2, + "F3": pixel.KeyF3, + "F4": pixel.KeyF4, + "F5": pixel.KeyF5, + "F6": pixel.KeyF6, + "F7": pixel.KeyF7, + "F8": pixel.KeyF8, + "F9": pixel.KeyF9, + "F10": pixel.KeyF10, + "F11": pixel.KeyF11, + "F12": pixel.KeyF12, + "Numpad0": pixel.KeyKP0, + "Numpad1": pixel.KeyKP1, + "Numpad2": pixel.KeyKP2, + "Numpad3": pixel.KeyKP3, + "Numpad4": pixel.KeyKP4, + "Numpad5": pixel.KeyKP5, + "Numpad6": pixel.KeyKP6, + "Numpad7": pixel.KeyKP7, + "Numpad8": pixel.KeyKP8, + "Numpad9": pixel.KeyKP9, + "NumpadDecimal": pixel.KeyKPDecimal, + "NumpadDivide": pixel.KeyKPDivide, + "NumpadMultiply": pixel.KeyKPMultiply, + "NumpadSubtract": pixel.KeyKPSubtract, + "NumpadAdd": pixel.KeyKPAdd, + "NumpadEnter": pixel.KeyKPEnter, + "NumpadEqual": pixel.KeyKPEqual, + "ShiftLeft": pixel.KeyLeftShift, + "ControlLeft": pixel.KeyLeftControl, + "AltLeft": pixel.KeyLeftAlt, + "MetaLeft": pixel.KeyLeftSuper, + "ShiftRight": pixel.KeyRightShift, + "ControlRight": pixel.KeyRightControl, + "AltRight": pixel.KeyRightAlt, + "MetaRight": pixel.KeyRightSuper, + "ContextMenu": pixel.KeyMenu, +} + +// initInput installs DOM keyboard listeners on the canvas. Events are +// translated into pixel.Button press/release/repeat on the shared +// InputHandler; printable characters are appended to the Typed buffer. +func (w *Window) initInput() { + keyDown := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + code := ev.Get("code").String() + btn, ok := domCodeToButton[code] + if !ok { + return nil + } + + if ev.Get("repeat").Bool() { + w.input.ButtonEvent(btn, pixel.Repeat) + } else { + w.input.ButtonEvent(btn, pixel.Press) + } + + // Feed printable characters into Typed buffer. Browsers give us the + // localized string in `event.key`; only single-code-point values + // correspond to real characters (others are "Enter", "Shift", etc). + key := ev.Get("key").String() + if r, ok := singleRune(key); ok { + w.input.CharEvent(r) + } + + // Swallow default handling for game-consumed keys so the browser + // doesn't scroll/tab-navigate while the canvas has focus. + if shouldPreventDefault(code) { + ev.Call("preventDefault") + } + return nil + }) + + keyUp := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + code := ev.Get("code").String() + btn, ok := domCodeToButton[code] + if !ok { + return nil + } + w.input.ButtonEvent(btn, pixel.Release) + if shouldPreventDefault(code) { + ev.Call("preventDefault") + } + return nil + }) + + // Blur clears all held keys so we don't get stuck-down buttons when the + // tab loses focus mid-keypress. + blur := js.FuncOf(func(this js.Value, args []js.Value) any { + for _, btn := range domCodeToButton { + w.input.ButtonEvent(btn, pixel.Release) + } + return nil + }) + + w.jsCanvas.Call("addEventListener", "keydown", keyDown) + w.jsCanvas.Call("addEventListener", "keyup", keyUp) + w.jsCanvas.Call("addEventListener", "blur", blur) +} + +func singleRune(s string) (rune, bool) { + if s == "" { + return 0, false + } + runes := []rune(s) + if len(runes) != 1 { + return 0, false + } + r := runes[0] + if r < 0x20 || r == 0x7f { + return 0, false + } + return r, true +} + +// shouldPreventDefault reports whether the browser's default handling of a +// key should be suppressed while the canvas has focus. +func shouldPreventDefault(code string) bool { + switch code { + case "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", + "Tab", "Backspace", "Slash", "Quote": + return true + } + if strings.HasPrefix(code, "F") && len(code) > 1 { + // Let F12 (devtools) through; block other F-keys. + if code == "F12" { + return false + } + return true + } + return false +} diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go new file mode 100644 index 0000000..b3cf605 --- /dev/null +++ b/backends/opengl/input_wasm.go @@ -0,0 +1,51 @@ +//go:build js && wasm + +package opengl + +import ( + "time" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/internal" +) + +// Input state is tracked via the same InputHandler the desktop backend uses; +// DOM event listeners (installed by Window.initInput in M4) feed it through +// ButtonEvent/CharEvent. Until then the listeners are empty and every query +// reports "not pressed". + +func (w *Window) Pressed(button pixel.Button) bool { return w.input.Curr.Buttons[button] } +func (w *Window) JustPressed(button pixel.Button) bool { return w.input.PressEvents[button] } +func (w *Window) JustReleased(button pixel.Button) bool { return w.input.ReleaseEvents[button] } +func (w *Window) Repeated(button pixel.Button) bool { return w.input.Curr.Repeat[button] } + +func (w *Window) MousePosition() pixel.Vec { return w.input.Curr.Mouse } +func (w *Window) MousePreviousPosition() pixel.Vec { return w.input.Prev.Mouse } +func (w *Window) SetMousePosition(v pixel.Vec) { w.input.SetMousePosition(v) } +func (w *Window) MouseInsideWindow() bool { return w.input.MouseInsideWindow } +func (w *Window) MouseScroll() pixel.Vec { return w.input.Curr.Scroll } +func (w *Window) MousePreviousScroll() pixel.Vec { return w.input.Prev.Scroll } +func (w *Window) Typed() string { return w.input.Curr.Typed } + +// SetButtonCallback / SetCharCallback / mouse callbacks exist so game code +// compiles unchanged; the WASM backend does not fire them yet. +func (w *Window) SetButtonCallback(cb func(win *Window, button pixel.Button, action pixel.Action)) { + w.buttonCallback = cb +} +func (w *Window) SetCharCallback(cb func(win *Window, r rune)) { w.charCallback = cb } +func (w *Window) SetMouseEnteredCallback(cb func(win *Window, entered bool)) { + w.mouseEnteredCallback = cb +} +func (w *Window) SetMouseMovedCallback(cb func(win *Window, pos pixel.Vec)) { + w.mouseMovedCallback = cb +} +func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { + w.scrollCallback = cb +} + +// UpdateInput commits the pending input events for the next frame. +func (w *Window) UpdateInput() { w.input.Update() } +func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } + +// Ensure internal package is referenced to avoid unused-import lint. +var _ = internal.InputHandler{} diff --git a/backends/opengl/joystick.go b/backends/opengl/joystick.go index 8043dbc..84e66c5 100644 --- a/backends/opengl/joystick.go +++ b/backends/opengl/joystick.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/joystick_wasm.go b/backends/opengl/joystick_wasm.go new file mode 100644 index 0000000..8272032 --- /dev/null +++ b/backends/opengl/joystick_wasm.go @@ -0,0 +1,17 @@ +//go:build js && wasm + +package opengl + +import "github.com/gopxl/pixel/v2" + +// Gamepads are out of scope for the WASM backend; all queries report +// "not connected". + +func (w *Window) JoystickPresent(pixel.Joystick) bool { return false } +func (w *Window) JoystickName(pixel.Joystick) string { return "" } +func (w *Window) JoystickButtonCount(pixel.Joystick) int { return 0 } +func (w *Window) JoystickAxisCount(pixel.Joystick) int { return 0 } +func (w *Window) JoystickPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickJustPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickJustReleased(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickAxis(pixel.Joystick, pixel.GamepadAxis) float64 { return 0 } diff --git a/backends/opengl/monitor.go b/backends/opengl/monitor.go index 8580bc6..3f7b878 100644 --- a/backends/opengl/monitor.go +++ b/backends/opengl/monitor.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/monitor_wasm.go b/backends/opengl/monitor_wasm.go new file mode 100644 index 0000000..971ef86 --- /dev/null +++ b/backends/opengl/monitor_wasm.go @@ -0,0 +1,32 @@ +//go:build js && wasm + +package opengl + +// Monitor represents a display. Under WASM we only ever have one logical +// monitor (the browser canvas host), so these are mostly stubs. +type Monitor struct{} + +// VideoMode mirrors the desktop shape. +type VideoMode struct { + Width int + Height int + RefreshRate int +} + +func PrimaryMonitor() *Monitor { return &Monitor{} } +func Monitors() []*Monitor { return []*Monitor{{}} } +func (m *Monitor) Name() string { return "Browser" } +func (m *Monitor) PhysicalSize() (width, height float64) { + return 0, 0 +} +func (m *Monitor) Position() (x, y float64) { return 0, 0 } +func (m *Monitor) Size() (width, height float64) { + return 0, 0 +} +func (m *Monitor) BitDepth() (red, green, blue int) { + return 8, 8, 8 +} +func (m *Monitor) RefreshRate() (rate float64) { return 60 } +func (m *Monitor) VideoModes() (vmodes []VideoMode) { + return nil +} diff --git a/backends/opengl/run.go b/backends/opengl/run.go index a11cb99..5be0178 100644 --- a/backends/opengl/run.go +++ b/backends/opengl/run.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/run_wasm.go b/backends/opengl/run_wasm.go new file mode 100644 index 0000000..ba6a8bf --- /dev/null +++ b/backends/opengl/run_wasm.go @@ -0,0 +1,12 @@ +//go:build js && wasm + +package opengl + +import "github.com/gopxl/mainthread/v2" + +// Run invokes the supplied function on the JS event-loop thread. The WASM +// mainthread shim calls the function inline, so this is a thin pass-through +// that keeps the call-site compatible with the desktop backend. +func Run(run func()) { + mainthread.Run(run) +} diff --git a/backends/opengl/window.go b/backends/opengl/window.go index efa0108..15b652b 100644 --- a/backends/opengl/window.go +++ b/backends/opengl/window.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go new file mode 100644 index 0000000..6f5fd56 --- /dev/null +++ b/backends/opengl/window_wasm.go @@ -0,0 +1,287 @@ +//go:build js && wasm + +package opengl + +import ( + "image/color" + "syscall/js" + + "github.com/gopxl/glhf/v2" + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/internal" + "github.com/pkg/errors" +) + +// CanvasElementID names the HTML canvas the WASM backend will attach to. +// Override before calling NewWindow to target a different element. +var CanvasElementID = "game" + +// WindowConfig mirrors the desktop struct so call-sites compile unchanged. +// Most fields are no-ops under WASM; only Title and Bounds are honored. +type WindowConfig struct { + Title string + Icon []pixel.Picture + Bounds pixel.Rect + Position pixel.Vec + Monitor *Monitor + Resizable bool + Undecorated bool + NoIconify bool + AlwaysOnTop bool + TransparentFramebuffer bool + VSync bool + Maximized bool + Invisible bool + SamplesMSAA int + BoundsLimits pixel.Rect +} + +// Window wraps an HTML5 canvas plus a WebGL2 rendering context. The internal +// Canvas handles all drawing; Update blits it to the default framebuffer and +// yields to requestAnimationFrame. +type Window struct { + bounds pixel.Rect + canvas *Canvas + + jsCanvas js.Value + gl js.Value + + closed bool + vsync bool + cursorVisible bool + + input internal.InputHandler + + buttonCallback func(win *Window, button pixel.Button, action pixel.Action) + charCallback func(win *Window, r rune) + mouseEnteredCallback func(win *Window, entered bool) + mouseMovedCallback func(win *Window, pos pixel.Vec) + scrollCallback func(win *Window, scroll pixel.Vec) +} + +var currWin *Window + +// NewWindow binds to the configured HTML canvas, creates a WebGL2 context, +// and initializes glhf. Returns an error if the canvas or context is missing. +func NewWindow(cfg WindowConfig) (*Window, error) { + doc := js.Global().Get("document") + jsCanvas := doc.Call("getElementById", CanvasElementID) + if !jsCanvas.Truthy() { + return nil, errors.Errorf("canvas #%s not found", CanvasElementID) + } + + gl := jsCanvas.Call("getContext", "webgl2", map[string]any{ + "alpha": false, + "antialias": cfg.SamplesMSAA > 0, + "premultipliedAlpha": true, + "preserveDrawingBuffer": false, + }) + if !gl.Truthy() { + return nil, errors.New("webgl2 not available") + } + + _, _, w, h := intBounds(cfg.Bounds) + jsCanvas.Set("width", w) + jsCanvas.Set("height", h) + + if cfg.Title != "" { + doc.Set("title", cfg.Title) + } + + glhf.SetContext(gl) + glhf.Init() + + win := &Window{ + bounds: cfg.Bounds, + jsCanvas: jsCanvas, + gl: gl, + vsync: cfg.VSync, + cursorVisible: true, + } + + win.canvas = NewCanvas(cfg.Bounds) + currWin = win + + // Ensure the canvas can receive keyboard focus inside iframes. + if jsCanvas.Get("tabIndex").Int() < 0 { + jsCanvas.Set("tabIndex", 0) + } + jsCanvas.Call("focus") + + win.initInput() + win.installContextLostHandler() + + return win, nil +} + +// installContextLostHandler logs and full-page-reloads on WebGL context loss. +// True in-place recovery is out of scope; reload is the simplest safe action. +func (w *Window) installContextLostHandler() { + lost := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + args[0].Call("preventDefault") + } + js.Global().Get("console").Call("warn", "webglcontextlost — reloading") + js.Global().Get("location").Call("reload") + return nil + }) + w.jsCanvas.Call("addEventListener", "webglcontextlost", lost) +} + +// Destroy releases the WebGL context. +func (w *Window) Destroy() { + w.closed = true +} + +// Update blits the internal canvas to the default framebuffer and yields to +// the browser's requestAnimationFrame so the next frame can be scheduled. +func (w *Window) Update() { + w.syncCanvasSize() + w.SwapBuffers() + w.UpdateInput() + awaitAnimationFrame() +} + +// syncCanvasSize updates the canvas backing store to match its current CSS size +// (times devicePixelRatio). Call before each frame so window resizes and +// fullscreen transitions propagate into window.Bounds() without a JS callback. +func (w *Window) syncCanvasSize() { + cssW := w.jsCanvas.Get("clientWidth").Int() + cssH := w.jsCanvas.Get("clientHeight").Int() + if cssW <= 0 || cssH <= 0 { + return + } + dpr := js.Global().Get("devicePixelRatio").Float() + if dpr < 1 { + dpr = 1 + } + targetW := int(float64(cssW) * dpr) + targetH := int(float64(cssH) * dpr) + curW := w.jsCanvas.Get("width").Int() + curH := w.jsCanvas.Get("height").Int() + if curW == targetW && curH == targetH { + return + } + w.jsCanvas.Set("width", targetW) + w.jsCanvas.Set("height", targetH) + w.bounds = pixel.R(0, 0, float64(targetW), float64(targetH)) + w.canvas.SetBounds(w.bounds) +} + +// SwapBuffers copies the current canvas texture into the default framebuffer. +func (w *Window) SwapBuffers() { + fbW, fbH := w.framebufferSize() + glhf.Bounds(0, 0, fbW, fbH) + glhf.Clear(0, 0, 0, 0) + + w.canvas.gf.Frame().Begin() + w.canvas.gf.Frame().Blit( + nil, + 0, 0, w.canvas.Texture().Width(), w.canvas.Texture().Height(), + 0, 0, fbW, fbH, + ) + w.canvas.gf.Frame().End() +} + +func (w *Window) framebufferSize() (int, int) { + return w.jsCanvas.Get("width").Int(), w.jsCanvas.Get("height").Int() +} + +// awaitAnimationFrame blocks the calling goroutine until the browser fires +// the next rAF callback. This is how the WASM backend yields time to the +// JS event loop once per simulated frame. +func awaitAnimationFrame() { + ch := make(chan struct{}, 1) + var cb js.Func + cb = js.FuncOf(func(this js.Value, args []js.Value) any { + cb.Release() + ch <- struct{}{} + return nil + }) + js.Global().Call("requestAnimationFrame", cb) + <-ch +} + +// Closed reports whether the window has been explicitly closed. The browser +// tab closing terminates the Go runtime, so this only reflects SetClosed. +func (w *Window) Closed() bool { return w.closed } +func (w *Window) SetClosed(closed bool) { w.closed = closed } + +func (w *Window) Bounds() pixel.Rect { return w.bounds } +func (w *Window) SetBounds(bounds pixel.Rect) { + w.bounds = bounds + _, _, width, height := intBounds(bounds) + w.jsCanvas.Set("width", width) + w.jsCanvas.Set("height", height) + w.canvas.SetBounds(bounds) +} +func (w *Window) SetBoundsLimits(pixel.Rect) {} + +func (w *Window) SetPos(pixel.Vec) {} +func (w *Window) GetPos() pixel.Vec { return pixel.ZV } +func (w *Window) SetTitle(title string) { + js.Global().Get("document").Set("title", title) +} + +func (w *Window) Focused() bool { + return js.Global().Get("document").Call("hasFocus").Bool() +} + +func (w *Window) SetVSync(vsync bool) { w.vsync = vsync } +func (w *Window) VSync() bool { return w.vsync } + +func (w *Window) SetCursorVisible(visible bool) { + w.cursorVisible = visible + style := w.jsCanvas.Get("style") + if visible { + style.Set("cursor", "auto") + } else { + style.Set("cursor", "none") + } +} +func (w *Window) SetCursorDisabled() { + w.cursorVisible = false + w.jsCanvas.Get("style").Set("cursor", "none") +} +func (w *Window) CursorVisible() bool { return w.cursorVisible } + +func (w *Window) SetMonitor(*Monitor) {} +func (w *Window) Monitor() *Monitor { return nil } + +// Note: must be called before any GL work. Kept for parity with desktop. +func (w *Window) begin() { + if currWin != w { + currWin = w + } +} +func (w *Window) end() {} + +// Drawing delegation ------------------------------------------------------- + +func (w *Window) MakeTriangles(t pixel.Triangles) pixel.TargetTriangles { + return w.canvas.MakeTriangles(t) +} +func (w *Window) MakePicture(p pixel.Picture) pixel.TargetPicture { + return w.canvas.MakePicture(p) +} +func (w *Window) SetMatrix(m pixel.Matrix) { w.canvas.SetMatrix(m) } +func (w *Window) SetColorMask(c color.Color) { w.canvas.SetColorMask(c) } +func (w *Window) SetComposeMethod(cmp pixel.ComposeMethod) { w.canvas.SetComposeMethod(cmp) } +func (w *Window) SetSmooth(smooth bool) { w.canvas.SetSmooth(smooth) } +func (w *Window) Smooth() bool { return w.canvas.Smooth() } +func (w *Window) Clear(c color.Color) { w.canvas.Clear(c) } +func (w *Window) Color(at pixel.Vec) pixel.RGBA { return w.canvas.Color(at) } +func (w *Window) Canvas() *Canvas { return w.canvas } + +// Show/Hide/Focus are no-ops in a browser environment. +func (w *Window) Show() {} +func (w *Window) Hide() {} +func (w *Window) Focus() { w.jsCanvas.Call("focus") } + +// Clipboard access goes through the browser's clipboard API when available. +// For now we return empty strings; writes are best-effort and ignored when +// the API is unavailable (e.g. insecure origin). +func (w *Window) ClipboardText() string { return "" } +func (w *Window) SetClipboardText(text string) {} +func (w *Window) Clipboard() string { return "" } +func (w *Window) SetClipboard(str string) {} From 22d42a88b321124f4c5b1d5cca16966d0f87d639 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:03:57 -0400 Subject: [PATCH 2/3] Wire DOM mouse events, refresh WASM docs, drop dead import shim The PR description claimed mouse input was wired through DOM events, but input_dom_wasm.go only installed keyboard listeners. Add mousedown / mouseup / mousemove / mouseenter / mouseleave / wheel / contextmenu handlers, map MouseEvent.button to pixel.MouseButtonN, and fire all user-registered callbacks (button, char, mouse moved/entered, scroll) so game code that sets them sees the same events as on desktop. Also drop the unused internal.InputHandler{} shim and its stale "not wired up yet" comment, and add a WebAssembly section to the top-level README describing how to load the canvas, which features are stubbed, and the required build command. Adjust the "Missing features" list so the HTML5 backend isn't still called out as missing. Co-Authored-By: Claude Opus 4.7 --- README.md | 23 ++++- backends/opengl/input_dom_wasm.go | 146 +++++++++++++++++++++++++++++- backends/opengl/input_wasm.go | 17 ++-- 3 files changed, 172 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6fbec63..88f927b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Pixel is in development and still missing few critical features. Here're the mos - Antialiasing (filtering is supported, though) - ~~Advanced window manipulation (cursor hiding, window icon, ...)~~ - Better support for Hi-DPI displays -- Mobile (and perhaps HTML5?) backend +- Mobile backend (an HTML5/WebGL2 backend ships under `GOOS=js GOARCH=wasm`) - ~~More advanced graphical effects (e.g. blur)~~ (solved with the addition of GLSL effects) - Tests and benchmarks - Vulkan support @@ -134,6 +134,27 @@ Pixel is in development and still missing few critical features. Here're the mos **Implementing these features will get us to the 1.0 release.** Contribute, so that it's as soon as possible! +## WebAssembly (browser) + +Pixel ships a WebGL2 backend under the `js && wasm` build tag, so a game +written against the standard `opengl` backend can also be compiled for the +browser: + +```sh +GOOS=js GOARCH=wasm go build -o game.wasm ./cmd/game +``` + +Load `game.wasm` from an HTML page that also serves Go's `wasm_exec.js` and +provides a canvas element. The backend attaches to `` by +default; override by setting `opengl.CanvasElementID` before calling +`opengl.NewWindow`. + +Some desktop-oriented features are stubbed in the browser: multi-monitor +queries, joysticks/gamepads, and custom cursor images are no-ops, and window +positioning is driven by CSS rather than by `SetPos`. Keyboard, mouse (buttons, +movement, scroll), text input, and resizing (including fullscreen) are wired +up through DOM events. + ## Requirements If you're using Windows and having trouble building Pixel, please check [this guide](./docs/Compilation/Building-Pixel-on-Windows.md) on the [wiki](./docs/README.md). diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go index 3f62a95..ba1c32a 100644 --- a/backends/opengl/input_dom_wasm.go +++ b/backends/opengl/input_dom_wasm.go @@ -119,9 +119,20 @@ var domCodeToButton = map[string]pixel.Button{ "ContextMenu": pixel.KeyMenu, } -// initInput installs DOM keyboard listeners on the canvas. Events are -// translated into pixel.Button press/release/repeat on the shared -// InputHandler; printable characters are appended to the Typed buffer. +// domMouseButton maps MouseEvent.button to the pixel.Button constants. +// Unmapped values yield ok=false. +var domMouseButton = map[int]pixel.Button{ + 0: pixel.MouseButton1, // left + 1: pixel.MouseButton3, // middle + 2: pixel.MouseButton2, // right + 3: pixel.MouseButton4, // back + 4: pixel.MouseButton5, // forward +} + +// initInput installs DOM keyboard and mouse listeners on the canvas. Events +// are translated into pixel.Button press/release/repeat events and mouse +// move/scroll events on the shared InputHandler; printable characters are +// appended to the Typed buffer. func (w *Window) initInput() { keyDown := js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) == 0 { @@ -136,8 +147,10 @@ func (w *Window) initInput() { if ev.Get("repeat").Bool() { w.input.ButtonEvent(btn, pixel.Repeat) + w.fireButtonCallback(btn, pixel.Repeat) } else { w.input.ButtonEvent(btn, pixel.Press) + w.fireButtonCallback(btn, pixel.Press) } // Feed printable characters into Typed buffer. Browsers give us the @@ -146,6 +159,9 @@ func (w *Window) initInput() { key := ev.Get("key").String() if r, ok := singleRune(key); ok { w.input.CharEvent(r) + if w.charCallback != nil { + w.charCallback(w, r) + } } // Swallow default handling for game-consumed keys so the browser @@ -167,6 +183,7 @@ func (w *Window) initInput() { return nil } w.input.ButtonEvent(btn, pixel.Release) + w.fireButtonCallback(btn, pixel.Release) if shouldPreventDefault(code) { ev.Call("preventDefault") } @@ -179,12 +196,135 @@ func (w *Window) initInput() { for _, btn := range domCodeToButton { w.input.ButtonEvent(btn, pixel.Release) } + for _, btn := range domMouseButton { + w.input.ButtonEvent(btn, pixel.Release) + } + return nil + }) + + mouseDown := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + btn, ok := domMouseButton[ev.Get("button").Int()] + if !ok { + return nil + } + // Prevent right-click context menu and middle-click autoscroll + // while the canvas has focus. + ev.Call("preventDefault") + w.input.ButtonEvent(btn, pixel.Press) + w.fireButtonCallback(btn, pixel.Press) + // Ensure subsequent keydown events continue to land on the canvas. + w.jsCanvas.Call("focus") + return nil + }) + + mouseUp := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + btn, ok := domMouseButton[ev.Get("button").Int()] + if !ok { + return nil + } + w.input.ButtonEvent(btn, pixel.Release) + w.fireButtonCallback(btn, pixel.Release) + return nil + }) + + mouseMove := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + pos := w.mousePosFromEvent(args[0]) + w.input.MouseMoveEvent(pos) + if w.mouseMovedCallback != nil { + w.mouseMovedCallback(w, pos) + } + return nil + }) + + mouseEnter := js.FuncOf(func(this js.Value, args []js.Value) any { + w.input.MouseEnteredEvent(true) + if w.mouseEnteredCallback != nil { + w.mouseEnteredCallback(w, true) + } + return nil + }) + + mouseLeave := js.FuncOf(func(this js.Value, args []js.Value) any { + w.input.MouseEnteredEvent(false) + if w.mouseEnteredCallback != nil { + w.mouseEnteredCallback(w, false) + } + return nil + }) + + wheel := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + ev.Call("preventDefault") + // Browsers deliver wheel deltas in pixels (deltaMode=0) or lines/ + // pages; we forward the raw pixel delta and let the caller scale it. + // Y is inverted so scrolling up yields a positive value, matching + // the desktop backend. + dx := ev.Get("deltaX").Float() + dy := -ev.Get("deltaY").Float() + w.input.MouseScrollEvent(dx, dy) + if w.scrollCallback != nil { + w.scrollCallback(w, pixel.V(dx, dy)) + } + return nil + }) + + contextMenu := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + args[0].Call("preventDefault") + } return nil }) w.jsCanvas.Call("addEventListener", "keydown", keyDown) w.jsCanvas.Call("addEventListener", "keyup", keyUp) w.jsCanvas.Call("addEventListener", "blur", blur) + w.jsCanvas.Call("addEventListener", "mousedown", mouseDown) + w.jsCanvas.Call("addEventListener", "mouseup", mouseUp) + w.jsCanvas.Call("addEventListener", "mousemove", mouseMove) + w.jsCanvas.Call("addEventListener", "mouseenter", mouseEnter) + w.jsCanvas.Call("addEventListener", "mouseleave", mouseLeave) + w.jsCanvas.Call("addEventListener", "wheel", wheel, map[string]any{"passive": false}) + w.jsCanvas.Call("addEventListener", "contextmenu", contextMenu) +} + +// mousePosFromEvent converts a MouseEvent's clientX/Y (in CSS pixels, +// relative to the viewport) into window-local coordinates in the backing +// store's pixel space. Y is flipped so the origin is at the bottom-left, +// matching the desktop backend. +func (w *Window) mousePosFromEvent(ev js.Value) pixel.Vec { + rect := w.jsCanvas.Call("getBoundingClientRect") + cssX := ev.Get("clientX").Float() - rect.Get("left").Float() + cssY := ev.Get("clientY").Float() - rect.Get("top").Float() + cssW := rect.Get("width").Float() + cssH := rect.Get("height").Float() + if cssW <= 0 || cssH <= 0 { + return pixel.ZV + } + bounds := w.bounds + x := bounds.Min.X + (cssX/cssW)*bounds.W() + y := bounds.Min.Y + (1-cssY/cssH)*bounds.H() + return pixel.V(x, y) +} + +// fireButtonCallback invokes the user-registered button callback if any. +func (w *Window) fireButtonCallback(btn pixel.Button, action pixel.Action) { + if w.buttonCallback != nil { + w.buttonCallback(w, btn, action) + } } func singleRune(s string) (rune, bool) { diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go index b3cf605..68a2919 100644 --- a/backends/opengl/input_wasm.go +++ b/backends/opengl/input_wasm.go @@ -6,13 +6,11 @@ import ( "time" "github.com/gopxl/pixel/v2" - "github.com/gopxl/pixel/v2/backends/internal" ) // Input state is tracked via the same InputHandler the desktop backend uses; -// DOM event listeners (installed by Window.initInput in M4) feed it through -// ButtonEvent/CharEvent. Until then the listeners are empty and every query -// reports "not pressed". +// DOM event listeners installed by Window.initInput feed it through +// ButtonEvent / CharEvent / MouseMoveEvent / MouseScrollEvent. func (w *Window) Pressed(button pixel.Button) bool { return w.input.Curr.Buttons[button] } func (w *Window) JustPressed(button pixel.Button) bool { return w.input.PressEvents[button] } @@ -27,8 +25,6 @@ func (w *Window) MouseScroll() pixel.Vec { return w.input.Curr.Scroll func (w *Window) MousePreviousScroll() pixel.Vec { return w.input.Prev.Scroll } func (w *Window) Typed() string { return w.input.Curr.Typed } -// SetButtonCallback / SetCharCallback / mouse callbacks exist so game code -// compiles unchanged; the WASM backend does not fire them yet. func (w *Window) SetButtonCallback(cb func(win *Window, button pixel.Button, action pixel.Action)) { w.buttonCallback = cb } @@ -44,8 +40,9 @@ func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { } // UpdateInput commits the pending input events for the next frame. -func (w *Window) UpdateInput() { w.input.Update() } -func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } +func (w *Window) UpdateInput() { w.input.Update() } -// Ensure internal package is referenced to avoid unused-import lint. -var _ = internal.InputHandler{} +// UpdateInputWait commits pending events. The timeout argument is accepted for +// API parity with the desktop backend but ignored under WASM — the browser +// event loop delivers events asynchronously. +func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } From 399d77dd8d1b90aed1c4fdadef6724c7159f2149 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:12:47 -0400 Subject: [PATCH 3/3] Add HTML5 Gamepad API support to the WASM backend Polls navigator.getGamepads() once per UpdateInput and feeds the results through the same internal.JoystickState machinery the desktop backend uses. Standard-layout pads are remapped so button / axis order matches the GLFW backend (including promoting LT/RT from analog buttons to the trigger axes); non-standard pads pass through as raw indices so applications can still address them. --- backends/opengl/input_wasm.go | 13 +- backends/opengl/joystick_wasm.go | 228 +++++++++++++++++++++++++++++-- backends/opengl/window_wasm.go | 3 +- 3 files changed, 227 insertions(+), 17 deletions(-) diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go index 68a2919..ec0b0a3 100644 --- a/backends/opengl/input_wasm.go +++ b/backends/opengl/input_wasm.go @@ -39,10 +39,17 @@ func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { w.scrollCallback = cb } -// UpdateInput commits the pending input events for the next frame. -func (w *Window) UpdateInput() { w.input.Update() } +// UpdateInput commits the pending input events for the next frame and polls +// the connected gamepads via the HTML5 Gamepad API. +func (w *Window) UpdateInput() { + w.input.Update() + w.updateJoystickInput() +} // UpdateInputWait commits pending events. The timeout argument is accepted for // API parity with the desktop backend but ignored under WASM — the browser // event loop delivers events asynchronously. -func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } +func (w *Window) UpdateInputWait(timeout time.Duration) { + w.input.Update() + w.updateJoystickInput() +} diff --git a/backends/opengl/joystick_wasm.go b/backends/opengl/joystick_wasm.go index 8272032..290cc55 100644 --- a/backends/opengl/joystick_wasm.go +++ b/backends/opengl/joystick_wasm.go @@ -2,16 +2,218 @@ package opengl -import "github.com/gopxl/pixel/v2" - -// Gamepads are out of scope for the WASM backend; all queries report -// "not connected". - -func (w *Window) JoystickPresent(pixel.Joystick) bool { return false } -func (w *Window) JoystickName(pixel.Joystick) string { return "" } -func (w *Window) JoystickButtonCount(pixel.Joystick) int { return 0 } -func (w *Window) JoystickAxisCount(pixel.Joystick) int { return 0 } -func (w *Window) JoystickPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickJustPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickJustReleased(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickAxis(pixel.Joystick, pixel.GamepadAxis) float64 { return 0 } +import ( + "syscall/js" + + "github.com/gopxl/pixel/v2" +) + +// Browser "standard" gamepad button indices (see +// https://www.w3.org/TR/gamepad/#remapping). +const ( + webBtnA = 0 + webBtnB = 1 + webBtnX = 2 + webBtnY = 3 + webBtnLeftBumper = 4 + webBtnRightBumper = 5 + webBtnLeftTrigger = 6 + webBtnRightTrigger = 7 + webBtnBack = 8 + webBtnStart = 9 + webBtnLeftThumb = 10 + webBtnRightThumb = 11 + webBtnDpadUp = 12 + webBtnDpadDown = 13 + webBtnDpadLeft = 14 + webBtnDpadRight = 15 + webBtnGuide = 16 +) + +// webButtonToPixel maps the browser's standard-layout button indices onto +// pixel's GamepadButton values. Triggers (6, 7) are intentionally omitted +// here and surface as AxisLeftTrigger / AxisRightTrigger so the API matches +// the desktop GLFW backend. +var webButtonToPixel = map[int]pixel.GamepadButton{ + webBtnA: pixel.GamepadA, + webBtnB: pixel.GamepadB, + webBtnX: pixel.GamepadX, + webBtnY: pixel.GamepadY, + webBtnLeftBumper: pixel.GamepadLeftBumper, + webBtnRightBumper: pixel.GamepadRightBumper, + webBtnBack: pixel.GamepadBack, + webBtnStart: pixel.GamepadStart, + webBtnGuide: pixel.GamepadGuide, + webBtnLeftThumb: pixel.GamepadLeftThumb, + webBtnRightThumb: pixel.GamepadRightThumb, + webBtnDpadUp: pixel.GamepadDpadUp, + webBtnDpadRight: pixel.GamepadDpadRight, + webBtnDpadDown: pixel.GamepadDpadDown, + webBtnDpadLeft: pixel.GamepadDpadLeft, +} + +// JoystickPresent reports whether a gamepad is connected in the given slot. +// +// This API is experimental. +func (w *Window) JoystickPresent(j pixel.Joystick) bool { + return w.currJoy.Connected[j] +} + +// JoystickName returns the navigator-supplied id for the gamepad in the given +// slot, or an empty string if no gamepad is present. +// +// This API is experimental. +func (w *Window) JoystickName(j pixel.Joystick) string { + return w.currJoy.Name[j] +} + +// JoystickButtonCount returns the number of buttons exposed by the connected +// gamepad. Returns 0 for disconnected slots. +// +// This API is experimental. +func (w *Window) JoystickButtonCount(j pixel.Joystick) int { + return len(w.currJoy.Buttons[j]) +} + +// JoystickAxisCount returns the number of axes exposed by the connected +// gamepad. Returns 0 for disconnected slots. +// +// This API is experimental. +func (w *Window) JoystickAxisCount(j pixel.Joystick) int { + return len(w.currJoy.Axis[j]) +} + +// JoystickPressed reports whether the given button is currently held. +// +// This API is experimental. +func (w *Window) JoystickPressed(j pixel.Joystick, button pixel.GamepadButton) bool { + return w.currJoy.GetButton(j, button) +} + +// JoystickJustPressed reports whether the given button transitioned to +// pressed since the last UpdateInput. +// +// This API is experimental. +func (w *Window) JoystickJustPressed(j pixel.Joystick, button pixel.GamepadButton) bool { + return w.currJoy.GetButton(j, button) && !w.prevJoy.GetButton(j, button) +} + +// JoystickJustReleased reports whether the given button transitioned to +// released since the last UpdateInput. +// +// This API is experimental. +func (w *Window) JoystickJustReleased(j pixel.Joystick, button pixel.GamepadButton) bool { + return !w.currJoy.GetButton(j, button) && w.prevJoy.GetButton(j, button) +} + +// JoystickAxis returns the current value of the given axis, in [-1, 1] for +// sticks and [0, 1] for triggers. +// +// This API is experimental. +func (w *Window) JoystickAxis(j pixel.Joystick, axis pixel.GamepadAxis) float64 { + return w.currJoy.GetAxis(j, axis) +} + +// updateJoystickInput polls navigator.getGamepads() and rotates the joystick +// state snapshots. Called once per frame from UpdateInput. +// +// The Gamepad API is poll-based: the browser updates snapshots asynchronously +// and the page observes state by re-reading the list each frame. +func (w *Window) updateJoystickInput() { + pads := browserGamepads() + for slot := 0; slot < pixel.NumJoysticks; slot++ { + joy := pixel.Joystick(slot) + if !pads.Truthy() || slot >= pads.Length() { + w.clearJoySlot(joy) + continue + } + pad := pads.Index(slot) + if !pad.Truthy() || !pad.Get("connected").Bool() { + w.clearJoySlot(joy) + continue + } + w.tempJoy.Connected[joy] = true + w.tempJoy.Name[joy] = pad.Get("id").String() + w.tempJoy.Buttons[joy], w.tempJoy.Axis[joy] = readPadState(pad) + } + w.prevJoy = w.currJoy + w.currJoy = w.tempJoy +} + +func (w *Window) clearJoySlot(j pixel.Joystick) { + w.tempJoy.Connected[j] = false + w.tempJoy.Buttons[j] = nil + w.tempJoy.Axis[j] = nil + w.tempJoy.Name[j] = "" +} + +// browserGamepads calls navigator.getGamepads() and returns the resulting +// array. Returns js.Undefined() when the host lacks the Gamepad API (older +// browsers, non-secure contexts, headless environments). +func browserGamepads() js.Value { + nav := js.Global().Get("navigator") + if !nav.Truthy() { + return js.Undefined() + } + fn := nav.Get("getGamepads") + if !fn.Truthy() { + return js.Undefined() + } + return nav.Call("getGamepads") +} + +// readPadState converts a browser Gamepad object into pixel's +// (buttons, axes) representation. Standard-layout pads are remapped so the +// button and axis order matches the desktop GLFW backend; non-standard pads +// pass through untouched so applications can still address raw indices. +func readPadState(pad js.Value) ([]pixel.Action, []float32) { + mapping := pad.Get("mapping").String() + btnsJS := pad.Get("buttons") + axesJS := pad.Get("axes") + btnN := btnsJS.Length() + axN := axesJS.Length() + + if mapping == "standard" { + buttons := make([]pixel.Action, pixel.NumGamepadButtons) + for i := 0; i < btnN; i++ { + target, ok := webButtonToPixel[i] + if !ok { + continue + } + if btnsJS.Index(i).Get("pressed").Bool() { + buttons[target] = pixel.Press + } else { + buttons[target] = pixel.Release + } + } + // Standard axes are [LX, LY, RX, RY]. Pixel also exposes the two + // triggers as axes, but on the web they are reported as analog + // buttons (indices 6 and 7) — promote them so downstream code sees + // the same axis layout as on desktop. + axes := make([]float32, pixel.NumAxes) + for i := 0; i < axN && i < 4; i++ { + axes[i] = float32(axesJS.Index(i).Float()) + } + if btnN > webBtnLeftTrigger { + axes[pixel.AxisLeftTrigger] = float32(btnsJS.Index(webBtnLeftTrigger).Get("value").Float()) + } + if btnN > webBtnRightTrigger { + axes[pixel.AxisRightTrigger] = float32(btnsJS.Index(webBtnRightTrigger).Get("value").Float()) + } + return buttons, axes + } + + buttons := make([]pixel.Action, btnN) + for i := 0; i < btnN; i++ { + if btnsJS.Index(i).Get("pressed").Bool() { + buttons[i] = pixel.Press + } else { + buttons[i] = pixel.Release + } + } + axes := make([]float32, axN) + for i := 0; i < axN; i++ { + axes[i] = float32(axesJS.Index(i).Float()) + } + return buttons, axes +} diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index 6f5fd56..90e1b14 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -50,7 +50,8 @@ type Window struct { vsync bool cursorVisible bool - input internal.InputHandler + input internal.InputHandler + prevJoy, currJoy, tempJoy internal.JoystickState buttonCallback func(win *Window, button pixel.Button, action pixel.Action) charCallback func(win *Window, r rune)