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/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..ba1c32a --- /dev/null +++ b/backends/opengl/input_dom_wasm.go @@ -0,0 +1,361 @@ +//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, +} + +// 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 { + 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) + 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 + // 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) + if w.charCallback != nil { + w.charCallback(w, 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) + w.fireButtonCallback(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) + } + 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) { + 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..ec0b0a3 --- /dev/null +++ b/backends/opengl/input_wasm.go @@ -0,0 +1,55 @@ +//go:build js && wasm + +package opengl + +import ( + "time" + + "github.com/gopxl/pixel/v2" +) + +// Input state is tracked via the same InputHandler the desktop backend uses; +// 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] } +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 } + +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 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() + w.updateJoystickInput() +} 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..290cc55 --- /dev/null +++ b/backends/opengl/joystick_wasm.go @@ -0,0 +1,219 @@ +//go:build js && wasm + +package opengl + +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/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..90e1b14 --- /dev/null +++ b/backends/opengl/window_wasm.go @@ -0,0 +1,288 @@ +//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 + prevJoy, currJoy, tempJoy internal.JoystickState + + 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) {}