From a86fadcc715422549b204da2a3edcda296161887 Mon Sep 17 00:00:00 2001 From: Christian Budde <4097562+CWBudde@users.noreply.github.com> Date: Sat, 12 Apr 2025 21:54:00 +0200 Subject: [PATCH 01/46] feat: added implemantation for wasm --- draw/window_glfw.go | 3 +- draw/window_sdl2.go | 4 +- draw/window_wasm.go | 684 +++++++++++++++++++++++++++++++++++++++++ draw/window_windows.go | 4 +- 4 files changed, 690 insertions(+), 5 deletions(-) create mode 100644 draw/window_wasm.go diff --git a/draw/window_glfw.go b/draw/window_glfw.go index 4001f6f..16aab2a 100644 --- a/draw/window_glfw.go +++ b/draw/window_glfw.go @@ -1,5 +1,6 @@ -//go:build glfw || (!windows && !sdl2) +//go:build (glfw || (!windows && !sdl2)) && !js // +build glfw !windows,!sdl2 +// +build !js package draw diff --git a/draw/window_sdl2.go b/draw/window_sdl2.go index c12f39f..db32db5 100644 --- a/draw/window_sdl2.go +++ b/draw/window_sdl2.go @@ -1,5 +1,5 @@ -//go:build sdl2 && !glfw -// +build sdl2,!glfw +//go:build sdl2 && !glfw && !js +// +build sdl2,!glfw,!js package draw diff --git a/draw/window_wasm.go b/draw/window_wasm.go new file mode 100644 index 0000000..06c42c1 --- /dev/null +++ b/draw/window_wasm.go @@ -0,0 +1,684 @@ +//go:build js && wasm +// +build js,wasm + +package draw + +import ( + "fmt" + "strings" + "syscall/js" +) + +type wasmWindow struct { + update UpdateFunction + canvas js.Value + ctx js.Value + width, height int + running bool + keyDown map[Key]bool + pressedKeys []Key + typedChars []rune + mouseX, mouseY int + mouseDown map[MouseButton]bool + wheelX float64 + wheelY float64 + clicks []MouseClick + imageCache map[string]js.Value + audioCtx js.Value + audioBuffers map[string]js.Value +} + +func RunWindow(title string, width, height int, update UpdateFunction) error { + doc := js.Global().Get("document") + canvas := doc.Call("getElementById", "gameCanvas") + if !canvas.Truthy() { + return js.Error{Value: js.ValueOf("canvas element not found")} + } + canvas.Set("width", width) + canvas.Set("height", height) + + ctx := canvas.Call("getContext", "2d") + + win := &wasmWindow{ + update: update, + canvas: canvas, + ctx: ctx, + width: width, + height: height, + running: true, + keyDown: make(map[Key]bool), + mouseDown: make(map[MouseButton]bool), + imageCache: make(map[string]js.Value), + audioCtx: js.Global().Get("AudioContext").New(), + audioBuffers: make(map[string]js.Value), + } + + js.Global().Call("addEventListener", "keydown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + code := event.Get("code").String() + key := toKey(code) + if key != 0 { + if !win.keyDown[key] { + win.pressedKeys = append(win.pressedKeys, key) + } + win.keyDown[key] = true + } + return nil + })) + + js.Global().Call("addEventListener", "keyup", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + code := event.Get("code").String() + key := toKey(code) + if key != 0 { + win.keyDown[key] = false + } + return nil + })) + + js.Global().Call("addEventListener", "keyup", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + code := event.Get("code").String() + key := toKey(code) + if key != 0 { + win.keyDown[key] = false + } + return nil + })) + + js.Global().Call("addEventListener", "keypress", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + char := rune(event.Get("key").String()[0]) + win.typedChars = append(win.typedChars, char) + return nil + })) + + canvas.Call("addEventListener", "mousemove", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + bounds := canvas.Call("getBoundingClientRect") + wX := event.Get("clientX").Int() - bounds.Get("left").Int() + wY := event.Get("clientY").Int() - bounds.Get("top").Int() + win.mouseX = wX + win.mouseY = wY + return nil + })) + + canvas.Call("addEventListener", "mousedown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + button := event.Get("button").Int() + win.mouseDown[MouseButton(button)] = true + win.clicks = append(win.clicks, MouseClick{ + X: win.mouseX, + Y: win.mouseY, + Button: MouseButton(button), + }) + return nil + })) + + canvas.Call("addEventListener", "mouseup", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + button := event.Get("button").Int() + win.mouseDown[MouseButton(button)] = false + return nil + })) + + canvas.Call("addEventListener", "wheel", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + deltaX := event.Get("deltaX").Float() + deltaY := event.Get("deltaY").Float() + + // Normalize direction (scrolling "up" is usually negative) + win.wheelX += deltaX + win.wheelY += deltaY + + // Prevent page from scrolling + event.Call("preventDefault") + return nil + })) + + //NOTE: We need to create it with: + + // Call update loop using requestAnimationFrame + var renderFrame js.Func + renderFrame = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if win.running { + win.update(win) + + clicks := win.clicks + win.clicks = nil // reset for next frame + win.clicks = append(win.clicks[:0], clicks...) + win.wheelX = 0 + win.wheelY = 0 + win.pressedKeys = nil + win.typedChars = nil + + js.Global().Call("requestAnimationFrame", renderFrame) + } + return nil + }) + js.Global().Call("requestAnimationFrame", renderFrame) + + // Prevent main from exiting + select {} +} + +func MathPi() float64 { + return js.Global().Get("Math").Get("PI").Float() +} + +func (w *wasmWindow) setColor(c Color) { + r := int(c.R * 255) + g := int(c.G * 255) + b := int(c.B * 255) + a := c.A + w.ctx.Set("fillStyle", fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a)) + w.ctx.Set("strokeStyle", fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a)) +} + +func (w *wasmWindow) loadImage(path string) (js.Value, error) { + if img, ok := w.imageCache[path]; ok { + return img, nil + } + + done := make(chan struct{}) + var img js.Value = js.Global().Get("Image").New() + var err error + + onLoad := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + w.imageCache[path] = img + close(done) + return nil + }) + onError := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + err = fmt.Errorf("failed to load image: %s", path) + close(done) + return nil + }) + + img.Set("onload", onLoad) + img.Set("onerror", onError) + img.Set("src", path) + + <-done + return img, err +} + +func (w *wasmWindow) loadSoundFile(path string) (js.Value, error) { + if buffer, ok := w.audioBuffers[path]; ok { + return buffer, nil + } + + done := make(chan struct{}) + var result js.Value + var err error + + fetchPromise := js.Global().Call("fetch", path) + then := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + resp := args[0] + resp.Call("arrayBuffer").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + arrayBuffer := args[0] + w.audioCtx.Call("decodeAudioData", arrayBuffer, + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + result = args[0] + w.audioBuffers[path] = result + close(done) + return nil + }), + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + err = fmt.Errorf("failed to decode audio: %s", path) + close(done) + return nil + }), + ) + return nil + })) + return nil + }) + + fetchPromise.Call("then", then) + <-done + + return result, err +} + +func toKey(code string) Key { + switch code { + case "KeyA": + return KeyA + case "KeyB": + return KeyB + case "KeyC": + return KeyC + case "KeyD": + return KeyD + case "KeyE": + return KeyE + case "KeyF": + return KeyF + case "KeyG": + return KeyG + case "KeyH": + return KeyH + case "KeyI": + return KeyI + case "KeyJ": + return KeyJ + case "KeyK": + return KeyK + case "KeyL": + return KeyL + case "KeyM": + return KeyM + case "KeyN": + return KeyN + case "KeyO": + return KeyO + case "KeyP": + return KeyP + case "KeyQ": + return KeyQ + case "KeyR": + return KeyR + case "KeyS": + return KeyS + case "KeyT": + return KeyT + case "KeyU": + return KeyU + case "KeyV": + return KeyV + case "KeyW": + return KeyW + case "KeyX": + return KeyX + case "KeyY": + return KeyY + case "KeyZ": + return KeyZ + case "ArrowLeft": + return KeyLeft + case "ArrowRight": + return KeyRight + case "ArrowUp": + return KeyUp + case "ArrowDown": + return KeyDown + case "Enter": + return KeyEnter + case "Space": + return KeySpace + case "Escape": + return KeyEscape + case "Backspace": + return KeyBackspace + case "Delete": + return KeyDelete + case "Insert": + return KeyInsert + case "Home": + return KeyHome + case "End": + return KeyEnd + case "PageUp": + return KeyPageUp + case "PageDown": + return KeyPageDown + case "ShiftLeft": + return KeyLeftShift + case "ShiftRight": + return KeyRightShift + case "ControlLeft": + return KeyLeftControl + case "ControlRight": + return KeyRightControl + case "AltLeft": + return KeyLeftAlt + case "AltRight": + return KeyRightAlt + case "Tab": + return KeyTab + case "CapsLock": + return KeyCapslock + case "NumEnter": + return KeyNumEnter + case "NumPlus": + return KeyNumAdd + case "NumMinus": + return KeyNumSubtract + case "NumMultiply": + return KeyNumMultiply + case "NumDivide": + return KeyNumDivide + case "Num0": + return KeyNum0 + case "Num1": + return KeyNum1 + case "Num2": + return KeyNum2 + case "Num3": + return KeyNum3 + case "Num4": + return KeyNum4 + case "Num5": + return KeyNum5 + case "Num6": + return KeyNum6 + case "Num7": + return KeyNum7 + case "Num8": + return KeyNum8 + case "Num9": + return KeyNum9 + case "Digit0": + return Key0 + case "Digit1": + return Key1 + case "Digit2": + return Key2 + case "Digit3": + return Key3 + case "Digit4": + return Key4 + case "Digit5": + return Key5 + case "Digit6": + return Key6 + case "Digit7": + return Key7 + case "Digit8": + return Key8 + case "Digit9": + return Key9 + case "KeyF1": + return KeyF1 + case "KeyF2": + return KeyF2 + case "KeyF3": + return KeyF3 + case "KeyF4": + return KeyF4 + case "KeyF5": + return KeyF5 + case "KeyF6": + return KeyF6 + case "KeyF7": + return KeyF7 + case "KeyF8": + return KeyF8 + case "KeyF9": + return KeyF9 + case "KeyF10": + return KeyF10 + case "KeyF11": + return KeyF11 + case "KeyF12": + return KeyF12 + } + return 0 +} + +func (w *wasmWindow) Close() { + w.running = false +} + +func (w *wasmWindow) Size() (int, int) { + return w.width, w.height +} + +func (w *wasmWindow) SetFullscreen(f bool) { + if f { + w.canvas.Call("requestFullscreen") + } else { + doc := js.Global().Get("document") + if doc.Call("exitFullscreen").Truthy() { + doc.Call("exitFullscreen") + } + } +} + +func (w *wasmWindow) ShowCursor(show bool) { + if show { + w.canvas.Get("style").Set("cursor", "default") + } else { + w.canvas.Get("style").Set("cursor", "none") + } +} + +func (w *wasmWindow) WasKeyPressed(key Key) bool { + for _, k := range w.pressedKeys { + if k == key { + return true + } + } + return false +} + +func (w *wasmWindow) IsKeyDown(key Key) bool { + return w.keyDown[key] +} + +func (w *wasmWindow) Characters() string { + return string(w.typedChars) +} + +func (w *wasmWindow) IsMouseDown(button MouseButton) bool { + return w.mouseDown[button] +} + +func (w *wasmWindow) Clicks() []MouseClick { + return w.clicks +} + +func (w *wasmWindow) MousePosition() (int, int) { + return w.mouseX, w.mouseY +} + +func (w *wasmWindow) MouseWheelX() float64 { + return w.wheelX +} + +func (w *wasmWindow) MouseWheelY() float64 { + return w.wheelY +} + +func (w *wasmWindow) DrawPoint(x, y int, c Color) { + w.FillRect(x, y, 1, 1, c) +} + +func (w *wasmWindow) DrawLine(x1, y1, x2, y2 int, c Color) { + w.setColor(c) + w.ctx.Call("beginPath") + w.ctx.Call("moveTo", x1, y1) + w.ctx.Call("lineTo", x2, y2) + w.ctx.Call("stroke") +} + +func (w *wasmWindow) DrawRect(x, y, width, height int, c Color) { + w.setColor(c) + w.ctx.Call("strokeRect", x, y, width, height) +} + +func (w *wasmWindow) FillRect(x, y, width, height int, c Color) { + w.setColor(c) + w.ctx.Call("fillRect", x, y, width, height) +} + +func (w *wasmWindow) DrawEllipse(x, y, width, height int, color Color) { + if width <= 0 || height <= 0 { + return + } + w.setColor(color) + w.ctx.Call("beginPath") + w.ctx.Call("ellipse", + x+width/2, // centerX + y+height/2, // centerY + width/2, // radiusX + height/2, // radiusY + 0, // rotation in radians + 0, // startAngle + 2*MathPi(), // endAngle + ) + w.ctx.Call("stroke") +} + +func (w *wasmWindow) FillEllipse(x, y, width, height int, color Color) { + if width <= 0 || height <= 0 { + return + } + w.setColor(color) + w.ctx.Call("beginPath") + w.ctx.Call("ellipse", + x+width/2, + y+height/2, + width/2, + height/2, + 0, + 0, + 2*MathPi(), + ) + w.ctx.Call("fill") +} + +func (w *wasmWindow) ImageSize(path string) (int, int, error) { + img, err := w.loadImage(path) + if err != nil { + return 0, 0, err + } + return img.Get("width").Int(), img.Get("height").Int(), nil +} + +func (w *wasmWindow) DrawImageFile(path string, x, y int) error { + img, err := w.loadImage(path) + if err != nil { + return err + } + w.ctx.Call("drawImage", img, x, y) + return nil +} + +func (w *wasmWindow) DrawImageFileTo(path string, x, y, w2, h2, rot int) error { + img, err := w.loadImage(path) + if err != nil { + return err + } + + // Save current context + w.ctx.Call("save") + + // Translate to center of target rect + w.ctx.Call("translate", x+w2/2, y+h2/2) + w.ctx.Call("rotate", float64(rot)*MathPi()/180) + + // Draw centered image + w.ctx.Call("drawImage", img, + 0, 0, img.Get("width").Int(), img.Get("height").Int(), // source + -w2/2, -h2/2, w2, h2, // destination (centered) + ) + + // Restore context + w.ctx.Call("restore") + return nil +} + +func (w *wasmWindow) DrawImageFileRotated(path string, x, y, rot int) error { + img, err := w.loadImage(path) + if err != nil { + return err + } + + w2 := img.Get("width").Int() + h2 := img.Get("height").Int() + + w.ctx.Call("save") + w.ctx.Call("translate", x+w2/2, y+h2/2) + w.ctx.Call("rotate", float64(rot)*MathPi()/180) + w.ctx.Call("drawImage", img, -w2/2, -h2/2) + w.ctx.Call("restore") + return nil +} + +func (w *wasmWindow) DrawImageFilePart(path string, + sx, sy, sw, sh, dx, dy, dw, dh, rot int, +) error { + img, err := w.loadImage(path) + if err != nil { + return err + } + + w.ctx.Call("save") + w.ctx.Call("translate", dx+dw/2, dy+dh/2) + w.ctx.Call("rotate", float64(rot)*MathPi()/180) + w.ctx.Call("drawImage", + img, + sx, sy, sw, sh, // source rect + -dw/2, -dh/2, dw, dh, // destination rect, centered + ) + w.ctx.Call("restore") + return nil +} + +func (w *wasmWindow) BlurImages(blur bool) { + w.ctx.Set("imageSmoothingEnabled", blur) +} + +func (w *wasmWindow) BlurText(blur bool) { + w.ctx.Set("imageSmoothingEnabled", blur) +} + +func (w *wasmWindow) GetTextSize(text string) (int, int) { + return w.GetScaledTextSize(text, 1.0) +} + +func (w *wasmWindow) GetScaledTextSize(text string, scale float32) (wOut, hOut int) { + if scale <= 0 { + return 0, 0 + } + + fontSize := 16.0 * float64(scale) + w.ctx.Set("font", fmt.Sprintf("%.2fpx monospace", fontSize)) + lines := strings.Split(text, "\n") + maxWidth := 0 + + for _, line := range lines { + width := w.ctx.Call("measureText", line).Get("width").Int() + if width > maxWidth { + maxWidth = width + } + } + + lineHeight := int(fontSize * 1.2) + return maxWidth, lineHeight * len(lines) +} + +func (w *wasmWindow) DrawText(text string, x, y int, color Color) { + w.DrawScaledText(text, x, y, 1.0, color) +} + +func (w *wasmWindow) DrawScaledText(text string, x, y int, scale float32, color Color) { + if scale <= 0 { + return + } + + w.setColor(color) + fontSize := 16.0 * float64(scale) // base size of 16, feel free to tweak + w.ctx.Set("font", fmt.Sprintf("%.2fpx monospace", fontSize)) + lines := strings.Split(text, "\n") + lineHeight := int(fontSize * 1.2) // line spacing + + for i, line := range lines { + w.ctx.Call("fillText", line, x, y+i*lineHeight) + } +} + +func (w *wasmWindow) PlaySoundFile(path string) error { + buffer, err := w.loadSoundFile(path) + if err != nil { + return err + } + + source := w.audioCtx.Call("createBufferSource") + source.Set("buffer", buffer) + source.Call("connect", w.audioCtx.Call("destination")) + source.Call("start") + return nil +} diff --git a/draw/window_windows.go b/draw/window_windows.go index 806969d..a473e12 100644 --- a/draw/window_windows.go +++ b/draw/window_windows.go @@ -1,5 +1,5 @@ -//go:build !sdl2 && !glfw -// +build !sdl2,!glfw +//go:build !sdl2 && !glfw && windows && !js +// +build !sdl2,!glfw,windows,!js package draw From 4c137ef402644cf6dfd75c9180067ebde16c21b8 Mon Sep 17 00:00:00 2001 From: Christian Budde <4097562+CWBudde@users.noreply.github.com> Date: Sat, 12 Apr 2025 22:22:57 +0200 Subject: [PATCH 02/46] feat: improved: README.md and added index.html --- README.md | 110 ++++++++++++++++++++++++++++------------ samples/worm/index.html | 52 +++++++++++++++++++ 2 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 samples/worm/index.html diff --git a/README.md b/README.md index 43661ba..858d737 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,91 @@ -prototype -========= +# prototype -Simply prototype 2D games using an easy, minimal interface that lets you draw simple primitives and images on the screen, easily handle mouse and keyboard events and play sounds. +Simply prototype 2D games using an easy, minimal interface that lets you draw simple primitives and images on the screen, easily handle mouse and keyboard events, and play sounds.  -Installation ------------- +## Installation Install the [Go programming language](https://golang.org/dl/). After clicking the download link you will be referred to the installation instructions for your specific operating system. -Install [Git](https://git-scm.com/downloads) and make it available in the PATH so the go tool can use it. - -For Linux and OS X you need a C compiler installed. On Windows this is not necessary. - -On Linux there are two backends available, GLFW and SDL2. For GLFW you need these libraries installed (tested on Linux Mint, other distros might be slightly different): - -`libx11-dev libxrandr-dev libgl1-mesa-dev libxcursor-dev libxinerama-dev libxi-dev` - -GLFW is used by default. To use SDL2 you need to add `-tags sdl2` to your Go builds, e.g. `go run -tags sdl2 main.go`. Also you need the SDL2 libraries installed: - -`libsdl2-dev libsdl2-mixer-dev libsdl2-image-dev` +Install [Git](https://git-scm.com/downloads) and make it available in the PATH so the Go tool can use it. + +For Linux and macOS, you need a C compiler installed. On Windows this is not necessary. + +### Supported Targets + +The prototype framework supports multiple targets: + +#### Windows (default) + +- Uses Direct3D 9 +- No additional dependencies needed + +#### Linux/macOS (GLFW backend) + +- Uses OpenGL via GLFW +- Install required packages (example for Ubuntu/Debian): + ```sh + sudo apt install libx11-dev libxrandr-dev libgl1-mesa-dev libxcursor-dev libxinerama-dev libxi-dev + ``` + +#### Linux (SDL2 backend) + +- Install SDL2 libraries: + ```sh + sudo apt install libsdl2-dev libsdl2-mixer-dev libsdl2-image-dev + ``` +- Use build tag: + ```sh + go run -tags sdl2 main.go + ``` + +#### WebAssembly (experimental) + +- Implemented via HTML5 Canvas and Web Audio using `syscall/js` +- Requires Go 1.21+ +- Add a file with build tag: + ```go + //go:build js && wasm + // +build js,wasm + ``` +- Compile using: + ```sh + GOOS=js GOARCH=wasm go build -o main.wasm + ``` +- Use a simple HTML wrapper with a canvas and `wasm_exec.js`: + ```html + + + + ``` + +To serve locally: + +```sh +python3 -m http.server +``` +## Installation (Library & Samples) -Install the library and samples by running the following on your command line: +Install the library and samples by running: - go get github.com/gonutz/prototype/... +```sh +go get github.com/gonutz/prototype/... +``` -Documentation -------------- +## Documentation -For a description of all library functions, see [the godoc page](http://godoc.org/github.com/gonutz/prototype/draw) for this project. Note that most of the functionality is in the Window interface and hence the descriptions are listed as code comments in the source for that type. +For a description of all library functions, see [the GoDoc page](http://godoc.org/github.com/gonutz/prototype/draw). Most functionality is in the `Window` interface, and documented via code comments. -Example -------- +## Example -```Go +```go package main import ( @@ -49,11 +99,9 @@ func main() { } func update(window draw.Window) { - // find the screen center w, h := window.Size() centerX, centerY := w/2, h/2 - // draw a button in the center of the screen mouseX, mouseY := window.MousePosition() mouseInCircle := math.Hypot(float64(mouseX-centerX), float64(mouseY-centerY)) < 20 color := draw.DarkRed @@ -66,16 +114,14 @@ func update(window draw.Window) { window.DrawScaledText("Close!", centerX-40, centerY+25, 1.6, draw.Green) } - // check all mouse clicks that happened during this frame for _, click := range window.Clicks() { dx, dy := click.X-centerX, click.Y-centerY - squareDist := dx*dx + dy*dy - if squareDist <= 20*20 { - // close the window and end the application + if dx*dx+dy*dy <= 20*20 { window.Close() } } } ``` - -This example displays a window with a round button in the middle to close it. It demonstrates some basic drawing and event handling code. + +This example displays a window with a round button in the middle to close it. It demonstrates basic drawing and event handling. + diff --git a/samples/worm/index.html b/samples/worm/index.html new file mode 100644 index 0000000..228bebf --- /dev/null +++ b/samples/worm/index.html @@ -0,0 +1,52 @@ + + +
+ +*wxV!1w06T0i{C6a2Q{W>CivWchQ{awTK8jt2;CWuuQ+; zvp{O#grDc%>~ngjcndrOJ^#70Kv!9=ea@(3p`+eD#QxjX#dhDCXgy{Tfjuf@T41U- zPBE4kdK;eWv-Ed$DzMknv>Td`rj;6Vd8H_GrkO zV~xDVL+VfB=J7Wa35wHlu6$1%9Je;sEt@CPN+(LoB|{`%#XZEYVp_-C6Qzr;3YEfq zflzRW&*JamA-oOTC}$PN&0fd`sWVH*oXV_YjAc~QN6||_kAG5!P=8YTQ9hIVlfRI9 zkv 7E(+3v%xc-KW|lJlA)!|~kS$^O>X(^hI71N_Vsi`Klt z95!t<5lp*{GO#}-8E)!x^&fP7b-%SEw5Fy%n|zve8d$R>*j({Y-U3W?%ecp}tz)0c z+R0u>J4jziI)m+~hxk)WpO|l=!J rGaO=x}y4eknZgZY7+z%ze$|4-j2pT)bv3wif> zM4nsj*6w$%L9Qm}pU#M5r-SdfX>V p_dm^2prHTwt1DG8?x84{^be zWB9Hgqu1+J=m@$C+Fb3crh!dmL0#OUxIpaYSbFSHnOt^N+D!UV(o0ex9xK+x{23D# zZ5Q!GXN9T42ZBz5AN)~#4R0YY%H7QsaxZYQIRCNxvCCM~SPtf9CX1O5D)5}%n_fzr zMsrZNQn}Q>DcO{F gD=Jx-mMZc9&Mqni(6A?Uu=8x22t>g_3C!uXvYOCjK|3YfQ0dHsH1cVEP{l`Uz_J zi}^%;J}-y&o;wOSjIEp)&MkIVb|q^e3t^pP<^f+jp5dbJq9@Rw(gx9*s2iz5>Mcq) zN*#GQnE}$3cBC@mLL!lPme7t+gv>=? 0lIgN>h|ys<2o#}8 zzeX?ByWQ<&rP4)Gs`QeiqohK-M9dUli|HOyCt58MitY;g3-y9+0)^lOm *ZKEa7-clz~Vd@1+7fL;O z3t35iOPWl=i2o3~6E%dbgjB*8WDa1)JMduG0Ud@~LlyXHT#CQMreQ?v3fc#CME{Dm zh?Ye*0+;q7JTuG*-w%xl1%mm(UO{7EA3#jCe}g~4|H-$+C-Ob_PV~~fcRYW1g6=Es z{%(is7~oBV^MJFtv(mB4k>x11uePTF%~)=Wwf(RzwMwjSEQ>8-%WLyYGt>MA)Q4?) zY@7p_^qFCbK>$6LjFyDNXMwt_W46Vl0FGE7;)os!M+rlMvx2Sy9e+1Ji(klF!ISY` zbEk2s+gUxTagBETa_*l% 2uI(t)1rqPGIu?lD?-nv?oB zs1Q#17jP?^@`}8G983O9+Ca)C)dF_uLUa)RCj3Dl5FP@Cks#mTHE;&p0A{Eg+`j?oU5 z 5_SznGd}H)1v?yvsKtiXb|KJ1_>a+ z1O6<&l>d{riPw^6;O27&17tnr%mbTqDSJ1&BXCsLSmRk-)@SBAz&AR^S;jC1nemdo zjGj)f2bCHKvVmvRMbuPk9pxycKX6(v$V zok(ZI4?lqC!HIAUbP5^<(V+MEX23;m>=rf`Q(?8}adZUAM!!V2M7u^Kk;jqck=%$W zd^tQLtO!?yj)g{sxS=1xoxwgqLJ)jgJJ2x@@IUnb>2L10`)>Lcg1u1hz2uz zU$_gGA`0dxBf)t^kM}>k_-locYc%&KPH@Bi}LBA$FA8&)Ub^W%erD1>0ns!d7R! zVx3`4vRW-lQoOKAa1_*vB6!DN&u`6l@^0~F@sz-Ip5PAWvbbM4+c{l0VfGXDQg#m8 z%(}vw&QgFN?<8{slgIqU*u&_{ATr+3H`6=O!?dTgm9!Q#7xfOPStiv)xdz-?GDwFm zlcxcGY9L)8O(My`jGZS=AjT4_3FinC2(g5E W? zOt;JR#I@Dc7x?~C=SAmiXSUPhcnjRYAcw%wXuob>1bh;~{@r%OHZO9RKa4Nnm-0^V z#sHS8;hy78=Bl__&Q;D_PA13BzQ HpG~(A&@>w70bFwEi>>t%Q1tI+>bCHB# >GLnos6b|gziOjS9EAp7BxifN7hDqM)&}~SHgdWJBO(tU%3eUQ|k~EDg>G^ zGuSE!1-}PQ1ZD-A1)~06{!{)L{#-u JVav8iJ33LmeB;0(dVATn}st3 Ye#SNc7^AHKhROMSh4@jkcrGpO-0 zZy#^0*XQ}>Ip Z)+v1`|Bm)!apOtqUe|eC$u` *D%z7FVi@Sp>HFvt=(%(l>>pQXtHD&p z0mXPvJw}~P?LcKw)s#n+U6gT@90~&7a$P5H0Q*=HIYjyi@V%VW4 BCxT@2zKNHl8^j}^g?8a3;qJ1g_pzq;dnRz{eu31)&RatgD|`T{} x-U9C+Bq5%wMRZjE`j+S9?1cz zMML;y_-J@RxK}tK91fL)?uGV*riHqMq`>q22;K;84NeHQ4+?{}z>mO{z~;cXK-&O6 zVDta*U-fVHPxiO>i~LSs0btu5V6MCRVto;Bx%Ywh2+*s3-efPy+vs`aIqzBP8Rco? z5qO;LpYG?;fviMUm|4WU4m|v5W)71Kwz$WP{frrmjtl|AO8-bdOJ7bONKc}pv~t=V z+D_UOT3Z^AW~P3io~5n?TV5J)b2Xs02PtzX-6^pYKe>>6gS?$QiQJaVC)-J1NtZ|) zNn-#5a7ZTNN8$zII^rl|GmwxPL5(jE))Ph(S`s(}3z*-_z~4+j+94uP=K}aPyc?bc zcL$Y5p=#(UbR1d=4FTCO12W>D@GJOMFwI?XIUdHUv1izEY#BBJYX;sxIMLteZS(*- zAMKB3qBM|ueT!ZPj4> OCTXY37Guf{rCJw z{L4WS)5#z2hy4cMci+FhqrR2CvA!-om5=1JdJ8~5Muq1C^+^HxqoKc{pQo> vhlpd5spy^HI4?v-| zlV^~7k(0?}zz$zX*GYRw^GE|onc$6*omfD;Pdq|g4yxUTC?!S+b$}}_0v|bp(1(yt zU=SQg5%Lgl#u{Wi(g{gGh=?Bk4*v@u2J<}{uyH)#V;%Gz`WJYpmC#tI6QqL3K;H`R zhxkc+Jw65Rg=YeW_Fz@mYwQxX3tNZ{$J$^Du+thq4<4bX(aq>g(1qrx806o&==bQo z=&9(&=#1z D5h$kxdG$goJ;NJ4}fafB Aj278Q__>=gM zm`~hFoJ$-^Y)e!UDZrOj5Z(~35e^Yn5hjD$=Mp3Yh+qWOe}P;=_94r`%=boefeylm z87_fe!B^o!z`ag`2f(dC7pNd5s)Ig5_n|Y;4rnnn4(b8rLQ;qTIK2x0fZxSW _6>UsSbjgY8k>O)!8&58fbAj7idLYX(EI2)bT7IRorVrYI|5#c zK?y)r>p*{=MX!MUYIAg9bR0lStEdX70T!`FY9e1FPa;<$N5Ee3XJlezK%_$?EfNEG z%Ny2&i^K22cf)^&4~EwR1{)t97;Ya<56i-|a3Ev~RfN8To`tRfMC}f(3e5uPP9NZQ z(nFFEH53k-gVn*G!T*A{gZoH>DeWjpz}tt&T5 rw|7b+Y?iP+dx45YYE>7Pl4+= zLfAxD09 $@0MpZuAZ&ok;ZN`*_%eJH zBo<5Isqj#ss=2TNC@Tipp?c^y^a{EIorewrm0bi)2K>|o$^)4+5B!qSgE!%&fT^C~ z*YH#LUVI%$d?w<90cSPG6LBHvQxG#@HP|nJp8LS%oB#+~1H8^8Y$(v?R-j|C7z@Ku z52`~e(I4n*^Z|MWJ&Eo^H=;`buEwAPfUj(Urhxp24LazLnxgg5;^?R7%jg}j-JAqU zvnjefIv1oW!=t^U9iw^C VS2i^hnFavDf zGMo?gN(5+-8?#_dpqnMwPwW%+3ZU-}b`>D-6!sUk7uyCl^cC15;0dN+W3fN5fmkoB z3wSHj49molv3TGU_!tu-fo_LEzwM|I)u6R#Ia&z%{t11HzCa(N_t2Z5{}<4F^aOes zOu &Co0~9Zg1+s2nXt&7jg1 z$ZzB$@)CJ~+yK=+i5x_BAnTE3Ac3ETj75flns-Io06t1Z6i5uhM#u;T`(Zn*hwA|z z3V;K813!cB0ndIB&WDe{`{3>HdUyrA2%Zf#p|PM713<#t8Eyl*kpXr}IUEBzLW2oF z#{-ZPGK0R *JG2Q}3#|ZbHy@e>O$9r_ zXlOVz80rV!XLW@-fW9?{a-ei58DLEgNgyHk-7*uRLPU_Fp?C=Q;V#^cn{flcpc=0Q zJXwwxgTDU6zk%L 4mf7xv%s{>#AkqG zIyk0*W9t7urU2#qzvKV@HuZl$@&7(K{eS;wCiwqZ;4E{%`R0N1E&%6W1g^6fTx}V? z99(%7$i>zGPU#M91~a_|)M_bUp!v{ju!T;ACIXch4N~S|pk@Q0zECfaw|9X$g2c5A x)Dmh2^dk$(0Qpfeln5yy1r!G`Ab~`X03d+_u|X~Apqk)I { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); From a26efe4515a9a98a6e08b6d7cdaf6eba98560c0f Mon Sep 17 00:00:00 2001 From: gonutz Date: Sat, 7 Jun 2025 09:16:51 +0200 Subject: [PATCH 11/46] WASM: draw pixelated lines --- draw/window_wasm.go | 46 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 2dfefa6..b26230b 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -468,10 +468,48 @@ func (w *wasmWindow) DrawPoint(x, y int, c Color) { // DrawLine renders a straight line between (x1, y1) and (x2, y2) with the given color. func (w *wasmWindow) DrawLine(x1, y1, x2, y2 int, c Color) { w.setColor(c) - w.ctx.Call("beginPath") - w.ctx.Call("moveTo", x1, y1) - w.ctx.Call("lineTo", x2, y2) - w.ctx.Call("stroke") + + // For extra nice pixels without the anti-aliasing, we use the Bresenham + // line drawing algorithm. This makes the web lines look the same as the + // desktop lines: pixelated. + + dx := abs(x2 - x1) + dy := abs(y2 - y1) + + sx := -1 + if x1 < x2 { + sx = 1 + } + + sy := -1 + if y1 < y2 { + sy = 1 + } + + err := dx - dy + + for { + w.ctx.Call("fillRect", x1, y1, 1, 1) + if x1 == x2 && y1 == y2 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x1 += sx + } + if e2 < dx { + err += dx + y1 += sy + } + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x } // DrawRect outlines a rectangle using stroke style at the given position and size. From 572c8ca4e78e575d5bc98227dae7e337cd484cd5 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sat, 7 Jun 2025 09:17:08 +0200 Subject: [PATCH 12/46] WASM: fix BlurImage BlurText would control the image blurring. Right now BlurText does nothing. --- draw/window_wasm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index b26230b..bbe95eb 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -653,7 +653,7 @@ func (w *wasmWindow) BlurImages(blur bool) { } func (w *wasmWindow) BlurText(blur bool) { - w.ctx.Set("imageSmoothingEnabled", blur) + // TODO Figure out how we want to draw and blur text. } // GetTextSize returns the width and height (in pixels) required to render the given text at default scale. From 9e40d9154b3bb89289bd366eed1f2bb275d0bd82 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 05:27:01 +0200 Subject: [PATCH 13/46] WASM: make right click not trigger context menu --- draw/window_wasm.go | 7 +++++-- samples/everything/main.go | 2 +- samples/everything/run.bat | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 samples/everything/run.bat diff --git a/draw/window_wasm.go b/draw/window_wasm.go index bbe95eb..85ebe3a 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -30,7 +30,6 @@ type wasmWindow struct { pendingImages map[string]bool audioCtx js.Value audioBuffers map[string]js.Value - eventHandlers []js.Func closeImagesOnce sync.Once } @@ -40,7 +39,6 @@ func (w *wasmWindow) bindEvent(target js.Value, event string, handler func(js.Va return nil }) target.Call("addEventListener", event, jsFunc) - w.eventHandlers = append(w.eventHandlers, jsFunc) return jsFunc } @@ -141,6 +139,11 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { e.Call("preventDefault") // prevent page scroll }) + // Suppress right clicks triggering the context menu. + win.bindEvent(canvas, "contextmenu", func(e js.Value) { + e.Call("preventDefault") + }) + // Main render loop using requestAnimationFrame var renderFrame js.Func renderFrame = js.FuncOf(func(this js.Value, args []js.Value) interface{} { diff --git a/samples/everything/main.go b/samples/everything/main.go index dea2b1d..35d4cee 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -112,7 +112,7 @@ func main() { if lastKey != 0 { text += "Last typed key: " + lastKey.String() + "\n" - }else { + } else { text += "Last typed key:\n" } diff --git a/samples/everything/run.bat b/samples/everything/run.bat new file mode 100644 index 0000000..3def764 --- /dev/null +++ b/samples/everything/run.bat @@ -0,0 +1,24 @@ +@echo off + +set GOOS=windows +set GOARCH=amd64 +go run . +if %errorlevel% neq 0 goto error + +set GOOS=js +set GOARCH=wasm +go build -o main.wasm +if %errorlevel% neq 0 goto error + +set GOOS=windows +set GOARCH=amd64 +go run serve.go +if %errorlevel% neq 0 goto error + +goto end + +:error +echo ERROR +pause + +:end From 28eb6827341873b4b17ef491118065a51162c978 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 07:36:43 +0200 Subject: [PATCH 14/46] WASM: fix MouseWheelX and Y --- draw/window_wasm.go | 4 ++-- samples/everything/main.go | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 85ebe3a..981e489 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -134,8 +134,8 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { // Mouse wheel win.bindEvent(canvas, "wheel", func(e js.Value) { - win.wheelX += e.Get("deltaX").Float() - win.wheelY += e.Get("deltaY").Float() + win.wheelX -= e.Get("deltaX").Float() / 100 + win.wheelY -= e.Get("deltaY").Float() / 100 e.Call("preventDefault") // prevent page scroll }) diff --git a/samples/everything/main.go b/samples/everything/main.go index 35d4cee..c8c17bd 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -18,6 +18,7 @@ func main() { wheelX float64 wheelY float64 cursorVisible = true + textScale = float32(1.0) ) draw.RunWindow("Everything", 800, 600, func(window draw.Window) { @@ -70,6 +71,8 @@ func main() { wheelX += window.MouseWheelX() wheelY += window.MouseWheelY() + textScale += float32(window.MouseWheelY() / 10) + if window.WasKeyPressed(draw.KeyF) { fullscreen = !fullscreen window.SetFullscreen(fullscreen) @@ -132,9 +135,9 @@ func main() { text += fmt.Sprintf("Mouse wheel: x %.2f, y %.2f\n", wheelX, wheelY) text = strings.TrimSuffix(text, "\n") - textW, textH := window.GetScaledTextSize(text, 1.5) + textW, textH := window.GetScaledTextSize(text, textScale) window.FillRect(5, 5, textW, textH, draw.DarkPurple) - window.DrawScaledText(text, 5, 5, 1.5, draw.White) + window.DrawScaledText(text, 5, 5, textScale, draw.White) }) } From 9365d5933bf050ab6818f86eb9dd5fcbc777832b Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 10:16:47 +0200 Subject: [PATCH 15/46] Windows: fix pixel issues when blurring images --- draw/window_windows.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/draw/window_windows.go b/draw/window_windows.go index a473e12..5c5eb1a 100644 --- a/draw/window_windows.go +++ b/draw/window_windows.go @@ -234,7 +234,12 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { device.SetRenderState(d3d9.RS_SRCBLEND, d3d9.BLEND_SRCALPHA) device.SetRenderState(d3d9.RS_DESTBLEND, d3d9.BLEND_INVSRCALPHA) device.SetRenderState(d3d9.RS_ALPHABLENDENABLE, 1) - // use nearest neighbor texture filtering + + device.SetSamplerState(0, d3d9.SAMP_ADDRESSU, d3d9.TADDRESS_BORDER) + device.SetSamplerState(0, d3d9.SAMP_ADDRESSV, d3d9.TADDRESS_BORDER) + device.SetSamplerState(0, d3d9.SAMP_BORDERCOLOR, 0) + + // Use nearest neighbor texture filtering. device.SetSamplerState(0, d3d9.SAMP_MINFILTER, d3d9.TEXF_NONE) device.SetSamplerState(0, d3d9.SAMP_MAGFILTER, d3d9.TEXF_NONE) From 8292c1fc1297f0657772a720bf2ceb390ba72e7d Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 10:17:43 +0200 Subject: [PATCH 16/46] WASM: fix DrawLine omitting end pixel To make the WASM port look the same as the desktops, we do not draw the last pixel of a line. --- draw/window_wasm.go | 2 +- samples/everything/main.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 981e489..c5fa339 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -492,10 +492,10 @@ func (w *wasmWindow) DrawLine(x1, y1, x2, y2 int, c Color) { err := dx - dy for { - w.ctx.Call("fillRect", x1, y1, 1, 1) if x1 == x2 && y1 == y2 { break } + w.ctx.Call("fillRect", x1, y1, 1, 1) e2 := 2 * err if e2 > -dy { err -= dy diff --git a/samples/everything/main.go b/samples/everything/main.go index c8c17bd..6a7c5d0 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -95,6 +95,8 @@ func main() { window.DrawRect(50, 440, 30, 40, draw.Yellow) window.FillEllipse(90, 440, 30, 40, draw.Purple) window.DrawEllipse(130, 440, 30, 40, draw.Blue) + window.DrawLine(170, 440, 250, 480, draw.LightBrown) + window.DrawLine(170, 445, 172, 447, draw.LightBlue) imgW, imgH, _ := window.ImageSize("meds.png") window.FillRect(9, 519, imgW+2, imgH+2, draw.DarkYellow) window.DrawImageFile("meds.png", 10, 520) From 4742e340b11a4f87ce706e85da808b9c84661869 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 10:18:26 +0200 Subject: [PATCH 17/46] WASM: Fix DrawRect being blurry When using a canvas stroke, we adjust the rectangle by half a pixel to align it to the actual pixel grid. --- draw/window_wasm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index c5fa339..2f871eb 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -518,7 +518,7 @@ func abs(x int) int { // DrawRect outlines a rectangle using stroke style at the given position and size. func (w *wasmWindow) DrawRect(x, y, width, height int, c Color) { w.setColor(c) - w.ctx.Call("strokeRect", x, y, width, height) + w.ctx.Call("strokeRect", float32(x)+0.5, float32(y)+0.5, width-1, height-1) } // FillRect renders a solid filled rectangle. From 295785f6d48296ba793026c12f55103781b774b8 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 10:57:16 +0200 Subject: [PATCH 18/46] WASM: draw lines/rects/ellipses like desktop All the basic drawing primitives are now pixelated like on the desktop. --- draw/window_wasm.go | 59 ++++++++++++++++++++--------------- samples/everything/main.go | 63 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 2f871eb..b32e37a 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -465,7 +465,8 @@ func (w *wasmWindow) MouseWheelY() float64 { // DrawPoint renders a single pixel (1x1 rectangle) at (x, y) using the specified color. func (w *wasmWindow) DrawPoint(x, y int, c Color) { - w.FillRect(x, y, 1, 1, c) + w.setColor(c) + w.ctx.Call("fillRect", x, y, 1, 1) } // DrawLine renders a straight line between (x1, y1) and (x2, y2) with the given color. @@ -517,12 +518,22 @@ func abs(x int) int { // DrawRect outlines a rectangle using stroke style at the given position and size. func (w *wasmWindow) DrawRect(x, y, width, height int, c Color) { - w.setColor(c) - w.ctx.Call("strokeRect", float32(x)+0.5, float32(y)+0.5, width-1, height-1) + if height == 1 { + w.DrawLine(x, y, x+width, y, c) + } else if width == 1 { + w.DrawLine(x, y, x, y+height, c) + } else if width > 0 && height > 0 { + w.setColor(c) + w.ctx.Call("strokeRect", float32(x)+0.5, float32(y)+0.5, width-1, height-1) + } } // FillRect renders a solid filled rectangle. func (w *wasmWindow) FillRect(x, y, width, height int, c Color) { + if width <= 0 || height <= 0 { + return + } + w.setColor(c) w.ctx.Call("fillRect", x, y, width, height) } @@ -532,18 +543,16 @@ func (w *wasmWindow) DrawEllipse(x, y, width, height int, color Color) { if width <= 0 || height <= 0 { return } + + outline := ellipseOutline(x, y, width, height) + if len(outline) == 0 { + return + } + w.setColor(color) - w.ctx.Call("beginPath") - w.ctx.Call("ellipse", - x+width/2, // centerX - y+height/2, // centerY - width/2, // radiusX - height/2, // radiusY - 0, // rotation in radians - 0, // startAngle - 2*math.Pi, // endAngle - ) - w.ctx.Call("stroke") + for _, p := range outline { + w.ctx.Call("fillRect", p.x, p.y, 1, 1) + } } // FillEllipse draws a filled ellipse within the bounding rectangle. @@ -551,18 +560,18 @@ func (w *wasmWindow) FillEllipse(x, y, width, height int, color Color) { if width <= 0 || height <= 0 { return } + + area := ellipseArea(x, y, width, height) + if len(area) == 0 { + return + } + w.setColor(color) - w.ctx.Call("beginPath") - w.ctx.Call("ellipse", - x+width/2, - y+height/2, - width/2, - height/2, - 0, - 0, - 2*math.Pi, - ) - w.ctx.Call("fill") + for len(area) > 1 { + start, end := area[0], area[1] + area = area[2:] + w.ctx.Call("fillRect", start.x, start.y, end.x-start.x+1, 1) + } } // ImageSize returns the native width and height of the image at the given path. diff --git a/samples/everything/main.go b/samples/everything/main.go index 6a7c5d0..5915b9c 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -95,8 +95,67 @@ func main() { window.DrawRect(50, 440, 30, 40, draw.Yellow) window.FillEllipse(90, 440, 30, 40, draw.Purple) window.DrawEllipse(130, 440, 30, 40, draw.Blue) - window.DrawLine(170, 440, 250, 480, draw.LightBrown) - window.DrawLine(170, 445, 172, 447, draw.LightBlue) + + window.DrawRect(170, 430, 0, 0, draw.Red) + window.DrawRect(170, 434, 1, 0, draw.Red) + window.DrawRect(170, 438, 0, 1, draw.Red) + window.DrawRect(170, 442, 1, 1, draw.Red) + window.DrawRect(170, 446, 2, 1, draw.LightRed) + window.DrawRect(170, 450, 1, 2, draw.Red) + window.DrawRect(170, 454, 2, 2, draw.LightRed) + window.DrawRect(170, 458, 3, 1, draw.Red) + window.DrawRect(170, 462, 1, 3, draw.LightRed) + window.DrawRect(170, 466, 3, 2, draw.Red) + window.DrawRect(170, 470, 2, 3, draw.LightRed) + window.DrawRect(170, 474, 4, 3, draw.Red) + window.DrawRect(170, 478, 3, 4, draw.LightRed) + + window.DrawEllipse(180, 430, 0, 0, draw.Blue) + window.DrawEllipse(180, 434, 1, 0, draw.Blue) + window.DrawEllipse(180, 438, 0, 1, draw.Blue) + window.DrawEllipse(180, 442, 1, 1, draw.Blue) + window.DrawEllipse(180, 446, 2, 1, draw.LightBlue) + window.DrawEllipse(180, 450, 1, 2, draw.Blue) + window.DrawEllipse(180, 454, 2, 2, draw.LightBlue) + window.DrawEllipse(180, 458, 3, 1, draw.Blue) + window.DrawEllipse(180, 462, 1, 3, draw.LightBlue) + window.DrawEllipse(180, 466, 3, 2, draw.Blue) + window.DrawEllipse(180, 470, 2, 3, draw.LightBlue) + window.DrawEllipse(180, 474, 4, 3, draw.Blue) + window.DrawEllipse(180, 478, 3, 4, draw.LightBlue) + + window.FillRect(190, 430, 0, 0, draw.Green) + window.FillRect(190, 434, 1, 0, draw.Green) + window.FillRect(190, 438, 0, 1, draw.Green) + window.FillRect(190, 442, 1, 1, draw.Green) + window.FillRect(190, 446, 2, 1, draw.LightGreen) + window.FillRect(190, 450, 1, 2, draw.Green) + window.FillRect(190, 454, 2, 2, draw.LightGreen) + window.FillRect(190, 458, 3, 1, draw.Green) + window.FillRect(190, 462, 1, 3, draw.LightGreen) + window.FillRect(190, 466, 3, 2, draw.Green) + window.FillRect(190, 470, 2, 3, draw.LightGreen) + window.FillRect(190, 474, 4, 3, draw.Green) + window.FillRect(190, 478, 3, 4, draw.LightGreen) + + window.FillEllipse(200, 430, 0, 0, draw.Yellow) + window.FillEllipse(200, 434, 1, 0, draw.Yellow) + window.FillEllipse(200, 438, 0, 1, draw.Yellow) + window.FillEllipse(200, 442, 1, 1, draw.Yellow) + window.FillEllipse(200, 446, 2, 1, draw.LightYellow) + window.FillEllipse(200, 450, 1, 2, draw.Yellow) + window.FillEllipse(200, 454, 2, 2, draw.LightYellow) + window.FillEllipse(200, 458, 3, 1, draw.Yellow) + window.FillEllipse(200, 462, 1, 3, draw.LightYellow) + window.FillEllipse(200, 466, 3, 2, draw.Yellow) + window.FillEllipse(200, 470, 2, 3, draw.LightYellow) + window.FillEllipse(200, 474, 4, 3, draw.Yellow) + window.FillEllipse(200, 478, 3, 4, draw.LightYellow) + + window.DrawLine(210, 440, 280, 480, draw.LightBrown) + window.DrawLine(210, 445, 212, 447, draw.LightBlue) + window.DrawLine(210, 450, 211, 451, draw.LightGreen) + imgW, imgH, _ := window.ImageSize("meds.png") window.FillRect(9, 519, imgW+2, imgH+2, draw.DarkYellow) window.DrawImageFile("meds.png", 10, 520) From 8a501e1b22284a113adb141708d8796e8d10b45f Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 21:19:41 +0200 Subject: [PATCH 19/46] WASM: handle keys like on desktop --- draw/window_wasm.go | 243 +++++++++++++++++++++++------------------ draw/window_windows.go | 2 +- 2 files changed, 138 insertions(+), 107 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index b32e37a..5107f70 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -74,8 +74,9 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { // Handles key press events: resumes audio and tracks pressed keys. win.bindEvent(js.Global(), "keydown", func(e js.Value) { - code := e.Get("code").String() - key := toKey(code) + keyCode := e.Get("code").String() + keyValue := e.Get("key").String() + key := toKey(keyCode, keyValue) if win.audioCtx.Get("state").String() == "suspended" { win.audioCtx.Call("resume") @@ -85,15 +86,19 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { win.pressedKeys = append(win.pressedKeys, key) } win.keyDown[key] = true + + e.Call("preventDefault") }) // Handles key release events win.bindEvent(js.Global(), "keyup", func(e js.Value) { - code := e.Get("code").String() - key := toKey(code) + keyCode := e.Get("code").String() + keyValue := e.Get("key").String() + key := toKey(keyCode, keyValue) if key != 0 { win.keyDown[key] = false } + e.Call("preventDefault") }) // Character input (text entry) @@ -280,111 +285,137 @@ func (w *wasmWindow) loadSoundFile(path string) (js.Value, error) { } var keyMap = map[string]Key{ - "KeyA": KeyA, - "KeyB": KeyB, - "KeyC": KeyC, - "KeyD": KeyD, - "KeyE": KeyE, - "KeyF": KeyF, - "KeyG": KeyG, - "KeyH": KeyH, - "KeyI": KeyI, - "KeyJ": KeyJ, - "KeyK": KeyK, - "KeyL": KeyL, - "KeyM": KeyM, - "KeyN": KeyN, - "KeyO": KeyO, - "KeyP": KeyP, - "KeyQ": KeyQ, - "KeyR": KeyR, - "KeyS": KeyS, - "KeyT": KeyT, - "KeyU": KeyU, - "KeyV": KeyV, - "KeyW": KeyW, - "KeyX": KeyX, - "KeyY": KeyY, - "KeyZ": KeyZ, - "Digit0": Key0, - "Digit1": Key1, - "Digit2": Key2, - "Digit3": Key3, - "Digit4": Key4, - "Digit5": Key5, - "Digit6": Key6, - "Digit7": Key7, - "Digit8": Key8, - "Digit9": Key9, - "Num0": KeyNum0, - "Num1": KeyNum1, - "Num2": KeyNum2, - "Num3": KeyNum3, - "Num4": KeyNum4, - "Num5": KeyNum5, - "Num6": KeyNum6, - "Num7": KeyNum7, - "Num8": KeyNum8, - "Num9": KeyNum9, - "KeyF1": KeyF1, - "KeyF2": KeyF2, - "KeyF3": KeyF3, - "KeyF4": KeyF4, - "KeyF5": KeyF5, - "KeyF6": KeyF6, - "KeyF7": KeyF7, - "KeyF8": KeyF8, - "KeyF9": KeyF9, - "KeyF10": KeyF10, - "KeyF11": KeyF11, - "KeyF12": KeyF12, - "KeyF13": KeyF13, - "KeyF14": KeyF14, - "KeyF15": KeyF15, - "KeyF16": KeyF16, - "KeyF17": KeyF17, - "KeyF18": KeyF18, - "KeyF19": KeyF19, - "KeyF20": KeyF20, - "KeyF21": KeyF21, - "KeyF22": KeyF22, - "KeyF23": KeyF23, - "KeyF24": KeyF24, - "Enter": KeyEnter, - "NumEnter": KeyNumEnter, - "ControlLeft": KeyLeftControl, - "ControlRight": KeyRightControl, - "ShiftLeft": KeyLeftShift, - "ShiftRight": KeyRightShift, - "AltLeft": KeyLeftAlt, - "AltRight": KeyRightAlt, - "ArrowLeft": KeyLeft, - "ArrowRight": KeyRight, - "ArrowUp": KeyUp, - "ArrowDown": KeyDown, - "Escape": KeyEscape, - "Space": KeySpace, - "Backspace": KeyBackspace, - "Tab": KeyTab, - "Home": KeyHome, - "End": KeyEnd, - "PageDown": KeyPageDown, - "PageUp": KeyPageUp, - "Delete": KeyDelete, - "Insert": KeyInsert, - "NumPlus": KeyNumAdd, - "NumMinus": KeyNumSubtract, - "NumMultiply": KeyNumMultiply, - "NumDivide": KeyNumDivide, - "CapsLock": KeyCapslock, - // TODO KeyPrint - // TODO KeyPause -} - -func toKey(code string) Key { + "KeyA": KeyA, + "KeyB": KeyB, + "KeyC": KeyC, + "KeyD": KeyD, + "KeyE": KeyE, + "KeyF": KeyF, + "KeyG": KeyG, + "KeyH": KeyH, + "KeyI": KeyI, + "KeyJ": KeyJ, + "KeyK": KeyK, + "KeyL": KeyL, + "KeyM": KeyM, + "KeyN": KeyN, + "KeyO": KeyO, + "KeyP": KeyP, + "KeyQ": KeyQ, + "KeyR": KeyR, + "KeyS": KeyS, + "KeyT": KeyT, + "KeyU": KeyU, + "KeyV": KeyV, + "KeyW": KeyW, + "KeyX": KeyX, + "KeyY": KeyY, + "KeyZ": KeyZ, + "Digit0": Key0, + "Digit1": Key1, + "Digit2": Key2, + "Digit3": Key3, + "Digit4": Key4, + "Digit5": Key5, + "Digit6": Key6, + "Digit7": Key7, + "Digit8": Key8, + "Digit9": Key9, + "Numpad0": KeyNum0, + "Numpad1": KeyNum1, + "Numpad2": KeyNum2, + "Numpad3": KeyNum3, + "Numpad4": KeyNum4, + "Numpad5": KeyNum5, + "Numpad6": KeyNum6, + "Numpad7": KeyNum7, + "Numpad8": KeyNum8, + "Numpad9": KeyNum9, + "KeyF1": KeyF1, + "KeyF2": KeyF2, + "KeyF3": KeyF3, + "KeyF4": KeyF4, + "KeyF5": KeyF5, + "KeyF6": KeyF6, + "KeyF7": KeyF7, + "KeyF8": KeyF8, + "KeyF9": KeyF9, + "KeyF10": KeyF10, + "KeyF11": KeyF11, + "KeyF12": KeyF12, + "KeyF13": KeyF13, + "KeyF14": KeyF14, + "KeyF15": KeyF15, + "KeyF16": KeyF16, + "KeyF17": KeyF17, + "KeyF18": KeyF18, + "KeyF19": KeyF19, + "KeyF20": KeyF20, + "KeyF21": KeyF21, + "KeyF22": KeyF22, + "KeyF23": KeyF23, + "KeyF24": KeyF24, + "Enter": KeyEnter, + "NumpadEnter": KeyNumEnter, + "ControlLeft": KeyLeftControl, + "ControlRight": KeyRightControl, + "ShiftLeft": KeyLeftShift, + "ShiftRight": KeyRightShift, + "AltLeft": KeyLeftAlt, + "AltRight": KeyRightAlt, + "ArrowLeft": KeyLeft, + "ArrowRight": KeyRight, + "ArrowUp": KeyUp, + "ArrowDown": KeyDown, + "Escape": KeyEscape, + "Space": KeySpace, + "Backspace": KeyBackspace, + "Tab": KeyTab, + "Home": KeyHome, + "End": KeyEnd, + "PageDown": KeyPageDown, + "PageUp": KeyPageUp, + "Delete": KeyDelete, + "Insert": KeyInsert, + "NumpadAdd": KeyNumAdd, + "NumpadSubtract": KeyNumSubtract, + "NumpadMultiply": KeyNumMultiply, + "NumpadDivide": KeyNumDivide, + "CapsLock": KeyCapslock, + "Pause": KeyPause, + "PrintScreen": KeyPrint, +} + +func toKey(code, value string) Key { + // JavaScript's keydown event gives us a key code and a key value. The key + // code is key layout independent. The key value represents the character on + // the key. Take for example a German keyboard where - compared to a US + // keyboard - the Z and Y keys are swapped. Here the key code for the Key + // between T and U, which on the German keyboard is the Z, will be "KeyY" + // while the key value will be "z" or "Z", depending on whether shift is + // held at the time of the key press. + // To replicate the behavior on the desktop, we need to handle the German Z + // key as KeyZ, even though JS gives us code KeyY for it. We use a + // combination of key code and key value to differentiate these. + if strings.HasPrefix(code, "Key") { + k := strings.TrimPrefix(code, "Key") + if isUpperCaseLetter(k) { + // Key code is in [KeyA..KeyZ]. + v := strings.ToUpper(value) + if isUpperCaseLetter(v) { + // Key value converted to upper-case is in [A..Z]. + return KeyA + Key(v[0]-'A') + } + } + } + return keyMap[code] // Defaults to 0 which is good. } +func isUpperCaseLetter(s string) bool { + return len(s) == 1 && 'A' <= s[0] && s[0] <= 'Z' +} + func (w *wasmWindow) Close() { w.running = false // TODO Stop all sounds. diff --git a/draw/window_windows.go b/draw/window_windows.go index 5c5eb1a..9252949 100644 --- a/draw/window_windows.go +++ b/draw/window_windows.go @@ -1257,7 +1257,7 @@ func rawInputToKey(kb w32.RAWKEYBOARD) (key Key, down bool) { return KeyLeftShift, down case w32.VK_RSHIFT: return KeyRightShift, down - case w32.VK_PRINT: + case w32.VK_PRINT, w32.VK_SNAPSHOT: return KeyPrint, down case w32.VK_PAUSE: return KeyPause, down From 2e14c8fd335178643fe4eceeaeb71eef573965f7 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 21:24:09 +0200 Subject: [PATCH 20/46] Refactor --- draw/window_wasm.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 5107f70..a247412 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -33,7 +33,7 @@ type wasmWindow struct { closeImagesOnce sync.Once } -func (w *wasmWindow) bindEvent(target js.Value, event string, handler func(js.Value)) js.Func { +func bindEvent(target js.Value, event string, handler func(js.Value)) js.Func { jsFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { handler(args[0]) return nil @@ -73,7 +73,7 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { win.imagesLoaded = make(chan struct{}) // Handles key press events: resumes audio and tracks pressed keys. - win.bindEvent(js.Global(), "keydown", func(e js.Value) { + bindEvent(js.Global(), "keydown", func(e js.Value) { keyCode := e.Get("code").String() keyValue := e.Get("key").String() key := toKey(keyCode, keyValue) @@ -91,7 +91,7 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Handles key release events - win.bindEvent(js.Global(), "keyup", func(e js.Value) { + bindEvent(js.Global(), "keyup", func(e js.Value) { keyCode := e.Get("code").String() keyValue := e.Get("key").String() key := toKey(keyCode, keyValue) @@ -102,7 +102,7 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Character input (text entry) - win.bindEvent(js.Global(), "keypress", func(e js.Value) { + bindEvent(js.Global(), "keypress", func(e js.Value) { keyStr := e.Get("key").String() if len(keyStr) > 0 { win.typedChars = append(win.typedChars, rune(keyStr[0])) @@ -110,14 +110,14 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Mouse movement tracking - win.bindEvent(canvas, "mousemove", func(e js.Value) { + bindEvent(canvas, "mousemove", func(e js.Value) { bounds := canvas.Call("getBoundingClientRect") win.mouseX = e.Get("clientX").Int() - bounds.Get("left").Int() win.mouseY = e.Get("clientY").Int() - bounds.Get("top").Int() }) // Mouse button down - win.bindEvent(canvas, "mousedown", func(e js.Value) { + bindEvent(canvas, "mousedown", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { win.mouseDown[button] = true @@ -130,7 +130,7 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Mouse button up - win.bindEvent(canvas, "mouseup", func(e js.Value) { + bindEvent(canvas, "mouseup", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { win.mouseDown[button] = false @@ -138,14 +138,14 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Mouse wheel - win.bindEvent(canvas, "wheel", func(e js.Value) { + bindEvent(canvas, "wheel", func(e js.Value) { win.wheelX -= e.Get("deltaX").Float() / 100 win.wheelY -= e.Get("deltaY").Float() / 100 e.Call("preventDefault") // prevent page scroll }) // Suppress right clicks triggering the context menu. - win.bindEvent(canvas, "contextmenu", func(e js.Value) { + bindEvent(canvas, "contextmenu", func(e js.Value) { e.Call("preventDefault") }) From cf154e890f0a7d7b888953390af016b677afb226 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sun, 8 Jun 2025 21:36:37 +0200 Subject: [PATCH 21/46] WASM: handle mouse events like on desktop --- draw/window_wasm.go | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index a247412..cc28ef2 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -110,32 +110,38 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { }) // Mouse movement tracking - bindEvent(canvas, "mousemove", func(e js.Value) { + bindEvent(doc, "mousemove", func(e js.Value) { bounds := canvas.Call("getBoundingClientRect") win.mouseX = e.Get("clientX").Int() - bounds.Get("left").Int() win.mouseY = e.Get("clientY").Int() - bounds.Get("top").Int() }) - // Mouse button down - bindEvent(canvas, "mousedown", func(e js.Value) { + // To determine whether the mouse buttons are currently up or down, we + // register the mouse down and up events on the document. + // To collect mouse clicks, we register the mouse down event on the canvas. + // Clicks outside the canvas are not reported. + bindEvent(doc, "mousedown", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { win.mouseDown[button] = true - win.clicks = append(win.clicks, MouseClick{ - X: win.mouseX, - Y: win.mouseY, - Button: MouseButton(button), - }) } }) - - // Mouse button up - bindEvent(canvas, "mouseup", func(e js.Value) { + bindEvent(doc, "mouseup", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { win.mouseDown[button] = false } }) + bindEvent(canvas, "mousedown", func(e js.Value) { + button := e.Get("button").Int() + if 0 <= button && button < int(mouseButtonCount) { + win.clicks = append(win.clicks, MouseClick{ + X: win.mouseX, + Y: win.mouseY, + Button: MouseButton(button), + }) + } + }) // Mouse wheel bindEvent(canvas, "wheel", func(e js.Value) { From d35b01483e207c6ebf3b32a8c763057ab7a61752 Mon Sep 17 00:00:00 2001 From: gonutz Date: Mon, 9 Jun 2025 23:37:26 +0200 Subject: [PATCH 22/46] Add drawsm tool to manage WASM builds --- README.md | 54 ++- cmd/drawsm/README.md | 19 ++ cmd/drawsm/main.go | 296 ++++++++++++++++ samples/everything/index.html | 23 -- samples/everything/run | 5 +- samples/everything/run.bat | 26 +- samples/everything/serve.go | 13 - samples/everything/wasm_exec.js | 575 -------------------------------- 8 files changed, 340 insertions(+), 671 deletions(-) create mode 100644 cmd/drawsm/README.md create mode 100644 cmd/drawsm/main.go delete mode 100644 samples/everything/index.html delete mode 100644 samples/everything/serve.go delete mode 100644 samples/everything/wasm_exec.js diff --git a/README.md b/README.md index 6fadbbe..c1ad9b7 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ # prototype -Simply prototype 2D games using an easy, minimal interface that lets you draw simple primitives and images on the screen, easily handle mouse and keyboard events, and play sounds. +Simply prototype 2D games using an easy, minimal interface that lets you draw +simple primitives and images on the screen, easily handle mouse and keyboard +events, and play sounds.  ## Installation -Install the [Go programming language](https://golang.org/dl/). After clicking the download link you will be referred to the installation instructions for your specific operating system. +Install the [Go programming language](https://golang.org/dl/). After clicking +the download link you will be referred to the installation instructions for your +specific operating system. -Install [Git](https://git-scm.com/downloads) and make it available in the PATH so the Go tool can use it. +Install [Git](https://git-scm.com/downloads) and make it available in the PATH +so the Go tool can use it. -For Linux and macOS, you need a C compiler installed. On Windows this is not necessary. +For Linux and macOS, you need a C compiler installed. On Windows this is not +necessary. ### Supported Targets @@ -42,44 +48,26 @@ The prototype framework supports multiple targets: #### WebAssembly (experimental) -- Implemented via HTML5 Canvas and Web Audio using `syscall/js` -- Requires Go 1.21+ -- Compile using: - ```sh - GOOS=js GOARCH=wasm go build -o main.wasm - ``` -- Use a simple HTML wrapper with a canvas and `wasm_exec.js` (from Go installation): - ```html - - - - ``` +To build and run a WASM version of your game, you can use the `drawsm` tool. +Install it with -> [!NOTE] -> The required `wasm_exec.js` file is included in the Go installation. You can find it in the `$(go env GOROOT)/misc/wasm` or `$(go env GOROOT)/lib/wasm` directory. Copy it to your project directory. + go install github.com/gonutz/prototype/cmd/drawsm@latest -To serve locally: +It allows you to run your game locally from within your project directory with -```sh -python3 -m http.server -``` + drawsm run -## Installation (Library & Samples) +or build it into the project directory with -Install the library and samples by running: + drawsm build -```sh -go get github.com/gonutz/prototype/... -``` +## Installation (Library & Samples) ## Documentation -For a description of all library functions, see [the GoDoc page](http://godoc.org/github.com/gonutz/prototype/draw). Most functionality is in the `Window` interface, and documented via code comments. +For a description of all library functions, see [the package doc +page](https://pkg.go.dev/github.com/gonutz/prototype/draw). Most functionality +is in the `Window` interface, and documented via code comments. ## Example diff --git a/cmd/drawsm/README.md b/cmd/drawsm/README.md new file mode 100644 index 0000000..7a791b3 --- /dev/null +++ b/cmd/drawsm/README.md @@ -0,0 +1,19 @@ +# drawsm + +This tool helps manage WASM builds of your games. + +Install it with `go install github.com/gonutz/prototype/cmd/drawsm@latest`. + +To run your game locally as a WASM build, call `drawsm run` from your project +directory. This will build the game and serve it locally on port 8080. It will +open the default browser at the local URL. + +To build your game use `drawsm build`. This will generate a template +`index.html` and copy your Go installation's `wasm_exec.js` to the project +directory. These files, along with the compilation output `main.wasm` are +necessary to server your game in the browser. + +To serve the built files use `drawsm serve`. This serves the game locally on +port 8080 and opens the default browser at this location. + +Call `drawsm help` for the tool's help text. diff --git a/cmd/drawsm/main.go b/cmd/drawsm/main.go new file mode 100644 index 0000000..a39c62b --- /dev/null +++ b/cmd/drawsm/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" +) + +func help() { + fmt.Println(`drawsm is a tool for managing github.com/prototype/draw WASM builds. + +Usage: + + drawsm + +The commands are: + + build + + Generates index.html, wasm_exec.js and main.wasm in the current + project. Serve these files to generate the game website. + + rebuild + + Same as build, but overwrites index.html, even if it was modified. + + serve + + Serve the files of a build or rebuild locally on port 8080. Opens + a web browser at the local URL. + + run + + Does not generate any files in the current directory. Instead it + builds main.wasm in a temporary folder and serves the template + index.html file locally on port 8080. Opens a web browser at the + local URL. + + help + + Displays this help text.`) +} + +func main() { + args := os.Args[1:] + if len(args) == 1 && (args[0] == "build" || args[0] == "rebuild") { + check(build(args[0] == "rebuild")) + } else if len(args) == 1 && args[0] == "serve" { + check(serve()) + } else if len(args) == 1 && args[0] == "run" { + check(run()) + } else if len(args) == 0 || len(args) == 1 && args[0] == "help" { + help() + } else { + fmt.Println("error: unknown command:", strings.Join(args, " ")) + help() + } +} + +func check(err error) { + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func build(rebuildTemplate bool) error { + // Copy the Go installation's wasm_exec.js file which is needed to run the + // build output. + output, err := exec.Command("go", "env", "GOROOT").CombinedOutput() + if err != nil { + return fmt.Errorf("failed to locate GOROOT: %w", err) + } + goroot := strings.TrimSpace(string(output)) + wasmExecPath := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js") + err = copyFile(wasmExecPath, "wasm_exec.js") + if err != nil { + return fmt.Errorf("failed to copy wasm_exec.js: %w", err) + } + + // Build the WASM project. + build := exec.Command("go", "build", "-o", "main.wasm") + build.Env = append( + os.Environ(), + "GOOS=js", + "GOARCH=wasm", + ) + buildOutput, err := build.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", buildOutput) + } + + existingIndexHtml, err := os.ReadFile("index.html") + if rebuildTemplate || err != nil { + // Either the command was rebuild or the command was build and + // index.html does not yet exist, so we create it. + err := os.WriteFile("index.html", indexHTML, 0666) + if err != nil { + return fmt.Errorf("failed to generate index.html: %w", err) + } + } else { + // index.html already exists. In case it matches what we would have + // generated, everything is fine. But if it differs from our template, + // we warn the user. An older generated file might be used or a + // previously generated index.html was modified. In this case we give + // the user a warning because the existing index.html might be + // incompatible. + if !bytes.Equal(existingIndexHtml, indexHTML) { + fmt.Println("warning: index.html already exists and differs from template; to re-generate it use drawsm rebuild") + } + } + + return nil +} + +func copyFile(from, to string) error { + data, err := os.ReadFile(from) + if err != nil { + return err + } + + return os.WriteFile(to, data, 0666) +} + +func serve() error { + var httpErr error + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + httpErr = http.ListenAndServe(":8080", http.FileServer(http.Dir("."))) + if httpErr != nil { + stop <- nil + } + }() + + // Wait a short while to let the http server start (or fail) and then open + // the localhost URL in the browser. + go func() { + time.Sleep(100 * time.Millisecond) + if httpErr == nil { + url := "http://localhost:8080" + err := openURL(url) + if err != nil { + fmt.Println(err) + } + } + }() + + <-stop + + return httpErr +} + +func run() error { + // Create a temporary folder for the WASM build output. + tempDir, err := os.MkdirTemp("", "drawsm_") + if err != nil { + return fmt.Errorf("failed to create temp build dir: %w", err) + } + defer os.RemoveAll(tempDir) + + // Read the Go installation's wasm_exec.js file which is needed to run the + // build output. + output, err := exec.Command("go", "env", "GOROOT").CombinedOutput() + if err != nil { + return fmt.Errorf("failed to locate GOROOT: %w", err) + } + goroot := strings.TrimSpace(string(output)) + wasmExec, err := os.ReadFile(filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")) + if err != nil { + return fmt.Errorf("failed to read wasm_exec.js: %w", err) + } + + // Build the WASM project into the temporary directory and read the file for + // later serving via HTTP. + mainWasmPath := filepath.Join(tempDir, "main.wasm") + build := exec.Command("go", "build", "-o", mainWasmPath) + build.Env = append( + os.Environ(), + "GOOS=js", + "GOARCH=wasm", + ) + + buildOutput, err := build.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", buildOutput) + } + + mainWasm, err := os.ReadFile(mainWasmPath) + if err != nil { + return fmt.Errorf("failed to read main.wasm: %w", err) + } + + // We serve three files specially: + // - index.html is served from our template code + // - wasm_exec.js is served from the Go installation (read above) + // - main.wasm, our build output, is served from the bytes read above + // The rest of the files are served from the project directory itself. This + // way, if there are images or other resources located in the project + // folder, they are found by the file server. + var httpErr error + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + httpErr = http.ListenAndServe(":8080", &handler{ + wasmExec: wasmExec, + mainWasm: mainWasm, + fileHandler: http.FileServer(http.Dir(".")), + }) + if httpErr != nil { + stop <- nil + } + }() + + // Wait a short while to let the http server start (or fail) and then open + // the localhost URL in the browser. + go func() { + time.Sleep(100 * time.Millisecond) + if httpErr == nil { + url := "http://localhost:8080" + err := openURL(url) + if err != nil { + fmt.Println(err) + } + } + }() + + <-stop + + return httpErr +} + +type handler struct { + mainWasm []byte + wasmExec []byte + fileHandler http.Handler +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Write(indexHTML) + } else if r.URL.Path == "/main.wasm" { + w.Write(h.mainWasm) + } else if r.URL.Path == "/wasm_exec.js" { + w.Write(h.wasmExec) + } else { + h.fileHandler.ServeHTTP(w, r) + } +} + +func openURL(url string) error { + switch runtime.GOOS { + case "windows": + return exec.Command("cmd", "/c", "start", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "darwin": + return exec.Command("open", url).Start() + default: + fmt.Println("Please navigate to", url) + return nil + } +} + +var indexHTML = []byte(` + + + + + + + + + +`) diff --git a/samples/everything/index.html b/samples/everything/index.html deleted file mode 100644 index 0343603..0000000 --- a/samples/everything/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - diff --git a/samples/everything/run b/samples/everything/run index ff02a52..27aa3d4 100755 --- a/samples/everything/run +++ b/samples/everything/run @@ -1,4 +1,3 @@ #!/bin/bash -GOOS=linux GOARCH=amd64 go run . || exit 1 -GOOS=js GOARCH=wasm go build -o main.wasm || exit 1 -GOOS=linux GOARCH=amd64 go run serve.go || exit 1 +go run . || exit 1 +drawsm run || exit 1 diff --git a/samples/everything/run.bat b/samples/everything/run.bat index 3def764..0a8938b 100644 --- a/samples/everything/run.bat +++ b/samples/everything/run.bat @@ -1,24 +1,2 @@ -@echo off - -set GOOS=windows -set GOARCH=amd64 -go run . -if %errorlevel% neq 0 goto error - -set GOOS=js -set GOARCH=wasm -go build -o main.wasm -if %errorlevel% neq 0 goto error - -set GOOS=windows -set GOARCH=amd64 -go run serve.go -if %errorlevel% neq 0 goto error - -goto end - -:error -echo ERROR -pause - -:end +@go run . +@drawsm run diff --git a/samples/everything/serve.go b/samples/everything/serve.go deleted file mode 100644 index 53842fd..0000000 --- a/samples/everything/serve.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build exclude - -package main - -import ( - "fmt" - "net/http" -) - -func main() { - fmt.Println("serving...") - http.ListenAndServe(":8080", http.FileServer(http.Dir("."))) -} diff --git a/samples/everything/wasm_exec.js b/samples/everything/wasm_exec.js deleted file mode 100644 index d71af9e..0000000 --- a/samples/everything/wasm_exec.js +++ /dev/null @@ -1,575 +0,0 @@ -// Copyright 2018 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -"use strict"; - -(() => { - const enosys = () => { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; - }; - - if (!globalThis.fs) { - let outputBuf = ""; - globalThis.fs = { - constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused - writeSync(fd, buf) { - outputBuf += decoder.decode(buf); - const nl = outputBuf.lastIndexOf("\n"); - if (nl != -1) { - console.log(outputBuf.substring(0, nl)); - outputBuf = outputBuf.substring(nl + 1); - } - return buf.length; - }, - write(fd, buf, offset, length, position, callback) { - if (offset !== 0 || length !== buf.length || position !== null) { - callback(enosys()); - return; - } - const n = this.writeSync(fd, buf); - callback(null, n); - }, - chmod(path, mode, callback) { callback(enosys()); }, - chown(path, uid, gid, callback) { callback(enosys()); }, - close(fd, callback) { callback(enosys()); }, - fchmod(fd, mode, callback) { callback(enosys()); }, - fchown(fd, uid, gid, callback) { callback(enosys()); }, - fstat(fd, callback) { callback(enosys()); }, - fsync(fd, callback) { callback(null); }, - ftruncate(fd, length, callback) { callback(enosys()); }, - lchown(path, uid, gid, callback) { callback(enosys()); }, - link(path, link, callback) { callback(enosys()); }, - lstat(path, callback) { callback(enosys()); }, - mkdir(path, perm, callback) { callback(enosys()); }, - open(path, flags, mode, callback) { callback(enosys()); }, - read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, - readdir(path, callback) { callback(enosys()); }, - readlink(path, callback) { callback(enosys()); }, - rename(from, to, callback) { callback(enosys()); }, - rmdir(path, callback) { callback(enosys()); }, - stat(path, callback) { callback(enosys()); }, - symlink(path, link, callback) { callback(enosys()); }, - truncate(path, length, callback) { callback(enosys()); }, - unlink(path, callback) { callback(enosys()); }, - utimes(path, atime, mtime, callback) { callback(enosys()); }, - }; - } - - if (!globalThis.process) { - globalThis.process = { - getuid() { return -1; }, - getgid() { return -1; }, - geteuid() { return -1; }, - getegid() { return -1; }, - getgroups() { throw enosys(); }, - pid: -1, - ppid: -1, - umask() { throw enosys(); }, - cwd() { throw enosys(); }, - chdir() { throw enosys(); }, - } - } - - if (!globalThis.path) { - globalThis.path = { - resolve(...pathSegments) { - return pathSegments.join("/"); - } - } - } - - if (!globalThis.crypto) { - throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); - } - - if (!globalThis.performance) { - throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); - } - - if (!globalThis.TextEncoder) { - throw new Error("globalThis.TextEncoder is not available, polyfill required"); - } - - if (!globalThis.TextDecoder) { - throw new Error("globalThis.TextDecoder is not available, polyfill required"); - } - - const encoder = new TextEncoder("utf-8"); - const decoder = new TextDecoder("utf-8"); - - globalThis.Go = class { - constructor() { - this.argv = ["js"]; - this.env = {}; - this.exit = (code) => { - if (code !== 0) { - console.warn("exit code:", code); - } - }; - this._exitPromise = new Promise((resolve) => { - this._resolveExitPromise = resolve; - }); - this._pendingEvent = null; - this._scheduledTimeouts = new Map(); - this._nextCallbackTimeoutID = 1; - - const setInt64 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); - } - - const setInt32 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - } - - const getInt64 = (addr) => { - const low = this.mem.getUint32(addr + 0, true); - const high = this.mem.getInt32(addr + 4, true); - return low + high * 4294967296; - } - - const loadValue = (addr) => { - const f = this.mem.getFloat64(addr, true); - if (f === 0) { - return undefined; - } - if (!isNaN(f)) { - return f; - } - - const id = this.mem.getUint32(addr, true); - return this._values[id]; - } - - const storeValue = (addr, v) => { - const nanHead = 0x7FF80000; - - if (typeof v === "number" && v !== 0) { - if (isNaN(v)) { - this.mem.setUint32(addr + 4, nanHead, true); - this.mem.setUint32(addr, 0, true); - return; - } - this.mem.setFloat64(addr, v, true); - return; - } - - if (v === undefined) { - this.mem.setFloat64(addr, 0, true); - return; - } - - let id = this._ids.get(v); - if (id === undefined) { - id = this._idPool.pop(); - if (id === undefined) { - id = this._values.length; - } - this._values[id] = v; - this._goRefCounts[id] = 0; - this._ids.set(v, id); - } - this._goRefCounts[id]++; - let typeFlag = 0; - switch (typeof v) { - case "object": - if (v !== null) { - typeFlag = 1; - } - break; - case "string": - typeFlag = 2; - break; - case "symbol": - typeFlag = 3; - break; - case "function": - typeFlag = 4; - break; - } - this.mem.setUint32(addr + 4, nanHead | typeFlag, true); - this.mem.setUint32(addr, id, true); - } - - const loadSlice = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - return new Uint8Array(this._inst.exports.mem.buffer, array, len); - } - - const loadSliceOfValues = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - const a = new Array(len); - for (let i = 0; i < len; i++) { - a[i] = loadValue(array + i * 8); - } - return a; - } - - const loadString = (addr) => { - const saddr = getInt64(addr + 0); - const len = getInt64(addr + 8); - return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); - } - - const testCallExport = (a, b) => { - this._inst.exports.testExport0(); - return this._inst.exports.testExport(a, b); - } - - const timeOrigin = Date.now() - performance.now(); - this.importObject = { - _gotest: { - add: (a, b) => a + b, - callExport: testCallExport, - }, - gojs: { - // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) - // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported - // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). - // This changes the SP, thus we have to update the SP used by the imported function. - - // func wasmExit(code int32) - "runtime.wasmExit": (sp) => { - sp >>>= 0; - const code = this.mem.getInt32(sp + 8, true); - this.exited = true; - delete this._inst; - delete this._values; - delete this._goRefCounts; - delete this._ids; - delete this._idPool; - this.exit(code); - }, - - // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) - "runtime.wasmWrite": (sp) => { - sp >>>= 0; - const fd = getInt64(sp + 8); - const p = getInt64(sp + 16); - const n = this.mem.getInt32(sp + 24, true); - fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); - }, - - // func resetMemoryDataView() - "runtime.resetMemoryDataView": (sp) => { - sp >>>= 0; - this.mem = new DataView(this._inst.exports.mem.buffer); - }, - - // func nanotime1() int64 - "runtime.nanotime1": (sp) => { - sp >>>= 0; - setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); - }, - - // func walltime() (sec int64, nsec int32) - "runtime.walltime": (sp) => { - sp >>>= 0; - const msec = (new Date).getTime(); - setInt64(sp + 8, msec / 1000); - this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); - }, - - // func scheduleTimeoutEvent(delay int64) int32 - "runtime.scheduleTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this._nextCallbackTimeoutID; - this._nextCallbackTimeoutID++; - this._scheduledTimeouts.set(id, setTimeout( - () => { - this._resume(); - while (this._scheduledTimeouts.has(id)) { - // for some reason Go failed to register the timeout event, log and try again - // (temporary workaround for https://github.com/golang/go/issues/28975) - console.warn("scheduleTimeoutEvent: missed timeout event"); - this._resume(); - } - }, - getInt64(sp + 8), - )); - this.mem.setInt32(sp + 16, id, true); - }, - - // func clearTimeoutEvent(id int32) - "runtime.clearTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this.mem.getInt32(sp + 8, true); - clearTimeout(this._scheduledTimeouts.get(id)); - this._scheduledTimeouts.delete(id); - }, - - // func getRandomData(r []byte) - "runtime.getRandomData": (sp) => { - sp >>>= 0; - crypto.getRandomValues(loadSlice(sp + 8)); - }, - - // func finalizeRef(v ref) - "syscall/js.finalizeRef": (sp) => { - sp >>>= 0; - const id = this.mem.getUint32(sp + 8, true); - this._goRefCounts[id]--; - if (this._goRefCounts[id] === 0) { - const v = this._values[id]; - this._values[id] = null; - this._ids.delete(v); - this._idPool.push(id); - } - }, - - // func stringVal(value string) ref - "syscall/js.stringVal": (sp) => { - sp >>>= 0; - storeValue(sp + 24, loadString(sp + 8)); - }, - - // func valueGet(v ref, p string) ref - "syscall/js.valueGet": (sp) => { - sp >>>= 0; - const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 32, result); - }, - - // func valueSet(v ref, p string, x ref) - "syscall/js.valueSet": (sp) => { - sp >>>= 0; - Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); - }, - - // func valueDelete(v ref, p string) - "syscall/js.valueDelete": (sp) => { - sp >>>= 0; - Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); - }, - - // func valueIndex(v ref, i int) ref - "syscall/js.valueIndex": (sp) => { - sp >>>= 0; - storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); - }, - - // valueSetIndex(v ref, i int, x ref) - "syscall/js.valueSetIndex": (sp) => { - sp >>>= 0; - Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); - }, - - // func valueCall(v ref, m string, args []ref) (ref, bool) - "syscall/js.valueCall": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const m = Reflect.get(v, loadString(sp + 16)); - const args = loadSliceOfValues(sp + 32); - const result = Reflect.apply(m, v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, result); - this.mem.setUint8(sp + 64, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, err); - this.mem.setUint8(sp + 64, 0); - } - }, - - // func valueInvoke(v ref, args []ref) (ref, bool) - "syscall/js.valueInvoke": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.apply(v, undefined, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueNew(v ref, args []ref) (ref, bool) - "syscall/js.valueNew": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.construct(v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueLength(v ref) int - "syscall/js.valueLength": (sp) => { - sp >>>= 0; - setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); - }, - - // valuePrepareString(v ref) (ref, int) - "syscall/js.valuePrepareString": (sp) => { - sp >>>= 0; - const str = encoder.encode(String(loadValue(sp + 8))); - storeValue(sp + 16, str); - setInt64(sp + 24, str.length); - }, - - // valueLoadString(v ref, b []byte) - "syscall/js.valueLoadString": (sp) => { - sp >>>= 0; - const str = loadValue(sp + 8); - loadSlice(sp + 16).set(str); - }, - - // func valueInstanceOf(v ref, t ref) bool - "syscall/js.valueInstanceOf": (sp) => { - sp >>>= 0; - this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); - }, - - // func copyBytesToGo(dst []byte, src ref) (int, bool) - "syscall/js.copyBytesToGo": (sp) => { - sp >>>= 0; - const dst = loadSlice(sp + 8); - const src = loadValue(sp + 32); - if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - // func copyBytesToJS(dst ref, src []byte) (int, bool) - "syscall/js.copyBytesToJS": (sp) => { - sp >>>= 0; - const dst = loadValue(sp + 8); - const src = loadSlice(sp + 16); - if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - "debug": (value) => { - console.log(value); - }, - } - }; - } - - async run(instance) { - if (!(instance instanceof WebAssembly.Instance)) { - throw new Error("Go.run: WebAssembly.Instance expected"); - } - this._inst = instance; - this.mem = new DataView(this._inst.exports.mem.buffer); - this._values = [ // JS values that Go currently has references to, indexed by reference id - NaN, - 0, - null, - true, - false, - globalThis, - this, - ]; - this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id - this._ids = new Map([ // mapping from JS values to reference ids - [0, 1], - [null, 2], - [true, 3], - [false, 4], - [globalThis, 5], - [this, 6], - ]); - this._idPool = []; // unused ids that have been garbage collected - this.exited = false; // whether the Go program has exited - - // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. - let offset = 4096; - - const strPtr = (str) => { - const ptr = offset; - const bytes = encoder.encode(str + "\0"); - new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); - offset += bytes.length; - if (offset % 8 !== 0) { - offset += 8 - (offset % 8); - } - return ptr; - }; - - const argc = this.argv.length; - - const argvPtrs = []; - this.argv.forEach((arg) => { - argvPtrs.push(strPtr(arg)); - }); - argvPtrs.push(0); - - const keys = Object.keys(this.env).sort(); - keys.forEach((key) => { - argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); - }); - argvPtrs.push(0); - - const argv = offset; - argvPtrs.forEach((ptr) => { - this.mem.setUint32(offset, ptr, true); - this.mem.setUint32(offset + 4, 0, true); - offset += 8; - }); - - // The linker guarantees global data starts from at least wasmMinDataAddr. - // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. - const wasmMinDataAddr = 4096 + 8192; - if (offset >= wasmMinDataAddr) { - throw new Error("total length of command line and environment variables exceeds limit"); - } - - this._inst.exports.run(argc, argv); - if (this.exited) { - this._resolveExitPromise(); - } - await this._exitPromise; - } - - _resume() { - if (this.exited) { - throw new Error("Go program has already exited"); - } - this._inst.exports.resume(); - if (this.exited) { - this._resolveExitPromise(); - } - } - - _makeFuncWrapper(id) { - const go = this; - return function () { - const event = { id: id, this: this, args: arguments }; - go._pendingEvent = event; - go._resume(); - return event.result; - }; - } - } -})(); From fa88d7d7417296db41792e1f27ce2474538d12f2 Mon Sep 17 00:00:00 2001 From: gonutz Date: Mon, 9 Jun 2025 23:49:25 +0200 Subject: [PATCH 23/46] drawsm: use right wasm path for Go 1.23+ --- cmd/drawsm/main.go | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/cmd/drawsm/main.go b/cmd/drawsm/main.go index a39c62b..b92df04 100644 --- a/cmd/drawsm/main.go +++ b/cmd/drawsm/main.go @@ -75,12 +75,10 @@ func check(err error) { func build(rebuildTemplate bool) error { // Copy the Go installation's wasm_exec.js file which is needed to run the // build output. - output, err := exec.Command("go", "env", "GOROOT").CombinedOutput() + wasmExecPath, err := locateWasmExecJs() if err != nil { - return fmt.Errorf("failed to locate GOROOT: %w", err) + return err } - goroot := strings.TrimSpace(string(output)) - wasmExecPath := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js") err = copyFile(wasmExecPath, "wasm_exec.js") if err != nil { return fmt.Errorf("failed to copy wasm_exec.js: %w", err) @@ -121,6 +119,25 @@ func build(rebuildTemplate bool) error { return nil } +func locateWasmExecJs() (string, error) { + output, err := exec.Command("go", "env", "GOROOT").CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to locate GOROOT: %w", err) + } + goroot := strings.TrimSpace(string(output)) + + wasmExecPath := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js") + if !fileExists(wasmExecPath) { + wasmExecPath = filepath.Join(goroot, "lib", "wasm", "wasm_exec.js") + } + return wasmExecPath, nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func copyFile(from, to string) error { data, err := os.ReadFile(from) if err != nil { @@ -169,12 +186,11 @@ func run() error { // Read the Go installation's wasm_exec.js file which is needed to run the // build output. - output, err := exec.Command("go", "env", "GOROOT").CombinedOutput() + wasmExecPath, err := locateWasmExecJs() if err != nil { - return fmt.Errorf("failed to locate GOROOT: %w", err) + return err } - goroot := strings.TrimSpace(string(output)) - wasmExec, err := os.ReadFile(filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")) + wasmExec, err := os.ReadFile(wasmExecPath) if err != nil { return fmt.Errorf("failed to read wasm_exec.js: %w", err) } From d018f5eeef3e7ce5f905124989c576a9204ab8c5 Mon Sep 17 00:00:00 2001 From: gonutz Date: Mon, 9 Jun 2025 23:53:19 +0200 Subject: [PATCH 24/46] Remove debug output from everything sample --- samples/everything/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/everything/main.go b/samples/everything/main.go index 5915b9c..4111482 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -80,7 +80,6 @@ func main() { if window.WasKeyPressed(draw.KeyC) { cursorVisible = !cursorVisible - fmt.Println("cursor", cursorVisible) window.ShowCursor(cursorVisible) } From d647e161d7b660d08a80a3cafbd3d2fac7790502 Mon Sep 17 00:00:00 2001 From: gonutz Date: Tue, 10 Jun 2025 00:08:32 +0200 Subject: [PATCH 25/46] WASM: fix preliminary text rendering Eventually we want to render text like for desktop. --- draw/window_wasm.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index cc28ef2..baaf2b7 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -729,8 +729,8 @@ func (w *wasmWindow) GetScaledTextSize(text string, scale float32) (wOut, hOut i } } - lineHeight := int(fontSize * 1.2) - return maxWidth, lineHeight * len(lines) + lineHeight := fontSize * 1.2 + return maxWidth, int(0.2*lineHeight + lineHeight*float64(len(lines)) + 0.5) } // DrawText renders a string at (x, y) using the given color and default scale (1.0). @@ -741,12 +741,10 @@ func (w *wasmWindow) DrawText(text string, x, y int, color Color) { // DrawScaledText renders a string of text at the given position with a scaling factor and color. // Text is drawn using a monospace font, and supports multi-line input (lines split by '\n'). func (w *wasmWindow) DrawScaledText(text string, x, y int, scale float32, color Color) { - // Ignore zero or negative scale if scale <= 0 { return } - // Set fill color for the text w.setColor(color) // Compute font size based on scaling factor @@ -759,11 +757,11 @@ func (w *wasmWindow) DrawScaledText(text string, x, y int, scale float32, color lines := strings.Split(text, "\n") // Define line spacing as 1.2x font size - lineHeight := int(fontSize * 1.2) // line spacing + lineHeight := fontSize * 1.2 // Draw each line at its vertical offset for i, line := range lines { - w.ctx.Call("fillText", line, x, y+i*lineHeight) + w.ctx.Call("fillText", line, x, fontSize+float64(y)+float64(i)*lineHeight) } } From 8b20cff61beabc19153be4e695f819c6ceeb49ac Mon Sep 17 00:00:00 2001 From: gonutz Date: Tue, 10 Jun 2025 20:46:30 +0200 Subject: [PATCH 26/46] WASM: improve key repeat --- draw/window_wasm.go | 158 +++++++++++++++++++++++++++++++------ samples/everything/main.go | 15 ++-- 2 files changed, 141 insertions(+), 32 deletions(-) diff --git a/draw/window_wasm.go b/draw/window_wasm.go index baaf2b7..9315539 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -87,7 +87,11 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { } win.keyDown[key] = true - e.Call("preventDefault") + if win.keyDown[KeyLeftControl] || win.keyDown[KeyRightControl] || + win.keyDown[KeyLeftAlt] || win.keyDown[KeyRightAlt] || + preventKeyDownDefault[key] { + e.Call("preventDefault") + } }) // Handles key release events @@ -98,7 +102,6 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { if key != 0 { win.keyDown[key] = false } - e.Call("preventDefault") }) // Character input (text entry) @@ -240,6 +243,7 @@ func (w *wasmWindow) loadImage(path string) (js.Value, error) { img.Set("src", path) return js.Null(), fmt.Errorf("image still loading: %s", path) + } // loadSoundFile fetches and decodes an audio file from the given path using the Web Audio API. @@ -337,30 +341,30 @@ var keyMap = map[string]Key{ "Numpad7": KeyNum7, "Numpad8": KeyNum8, "Numpad9": KeyNum9, - "KeyF1": KeyF1, - "KeyF2": KeyF2, - "KeyF3": KeyF3, - "KeyF4": KeyF4, - "KeyF5": KeyF5, - "KeyF6": KeyF6, - "KeyF7": KeyF7, - "KeyF8": KeyF8, - "KeyF9": KeyF9, - "KeyF10": KeyF10, - "KeyF11": KeyF11, - "KeyF12": KeyF12, - "KeyF13": KeyF13, - "KeyF14": KeyF14, - "KeyF15": KeyF15, - "KeyF16": KeyF16, - "KeyF17": KeyF17, - "KeyF18": KeyF18, - "KeyF19": KeyF19, - "KeyF20": KeyF20, - "KeyF21": KeyF21, - "KeyF22": KeyF22, - "KeyF23": KeyF23, - "KeyF24": KeyF24, + "F1": KeyF1, + "F2": KeyF2, + "F3": KeyF3, + "F4": KeyF4, + "F5": KeyF5, + "F6": KeyF6, + "F7": KeyF7, + "F8": KeyF8, + "F9": KeyF9, + "F10": KeyF10, + "F11": KeyF11, + "F12": KeyF12, + "F13": KeyF13, + "F14": KeyF14, + "F15": KeyF15, + "F16": KeyF16, + "F17": KeyF17, + "F18": KeyF18, + "F19": KeyF19, + "F20": KeyF20, + "F21": KeyF21, + "F22": KeyF22, + "F23": KeyF23, + "F24": KeyF24, "Enter": KeyEnter, "NumpadEnter": KeyNumEnter, "ControlLeft": KeyLeftControl, @@ -392,6 +396,108 @@ var keyMap = map[string]Key{ "PrintScreen": KeyPrint, } +var preventKeyDownDefault = map[Key]bool{ + KeyA: false, + KeyB: false, + KeyC: false, + KeyD: false, + KeyE: false, + KeyF: false, + KeyG: false, + KeyH: false, + KeyI: false, + KeyJ: false, + KeyK: false, + KeyL: false, + KeyM: false, + KeyN: false, + KeyO: false, + KeyP: false, + KeyQ: false, + KeyR: false, + KeyS: false, + KeyT: false, + KeyU: false, + KeyV: false, + KeyW: false, + KeyX: false, + KeyY: false, + KeyZ: false, + Key0: false, + Key1: false, + Key2: false, + Key3: false, + Key4: false, + Key5: false, + Key6: false, + Key7: false, + Key8: false, + Key9: false, + KeyNum0: false, + KeyNum1: false, + KeyNum2: false, + KeyNum3: false, + KeyNum4: false, + KeyNum5: false, + KeyNum6: false, + KeyNum7: false, + KeyNum8: false, + KeyNum9: false, + KeyF1: true, + KeyF2: true, + KeyF3: true, + KeyF4: true, + KeyF5: true, + KeyF6: true, + KeyF7: true, + KeyF8: true, + KeyF9: true, + KeyF10: true, + KeyF11: true, + KeyF12: true, + KeyF13: true, + KeyF14: true, + KeyF15: true, + KeyF16: true, + KeyF17: true, + KeyF18: true, + KeyF19: true, + KeyF20: true, + KeyF21: true, + KeyF22: true, + KeyF23: true, + KeyF24: true, + KeyEnter: false, + KeyNumEnter: false, + KeyLeftControl: true, + KeyRightControl: true, + KeyLeftShift: true, + KeyRightShift: true, + KeyLeftAlt: true, + KeyRightAlt: true, + KeyLeft: false, + KeyRight: false, + KeyUp: false, + KeyDown: false, + KeyEscape: false, + KeySpace: false, + KeyBackspace: false, + KeyTab: false, + KeyHome: true, + KeyEnd: true, + KeyPageDown: true, + KeyPageUp: true, + KeyDelete: false, + KeyInsert: false, + KeyNumAdd: false, + KeyNumSubtract: false, + KeyNumMultiply: false, + KeyNumDivide: false, + KeyCapslock: true, + KeyPrint: true, + KeyPause: true, +} + func toKey(code, value string) Key { // JavaScript's keydown event gives us a key code and a key value. The key // code is key layout independent. The key value represents the character on diff --git a/samples/everything/main.go b/samples/everything/main.go index 4111482..3d2b828 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -13,7 +13,7 @@ func main() { blurImages bool blurText bool characters string - lastKey draw.Key + lastKeys []draw.Key lastClick draw.MouseClick wheelX float64 wheelY float64 @@ -46,7 +46,10 @@ func main() { for key := draw.KeyA; key <= draw.KeyPause; key++ { if window.WasKeyPressed(key) { - lastKey = key + lastKeys = append(lastKeys, key) + if len(lastKeys) > 3 { + lastKeys = lastKeys[len(lastKeys)-3:] + } } } @@ -173,11 +176,11 @@ func main() { text += "S: Play Sound\n" text += "Text written so far: " + characters + "\n" - if lastKey != 0 { - text += "Last typed key: " + lastKey.String() + "\n" - } else { - text += "Last typed key:\n" + lastKeyTexts := make([]string, len(lastKeys)) + for i, k := range lastKeys { + lastKeyTexts[i] = k.String() } + text += "Last typed key: " + strings.Join(lastKeyTexts, " ") + "\n" text += "Pressed keys: " + keyDowns + "\n" text += "Pressed mouse buttons: " + mouseDowns + "\n" From c3f5755b2a34fcbc040756aaf696dde71e21b170 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sat, 14 Jun 2025 08:18:29 +0200 Subject: [PATCH 27/46] Drawsm tool: separate serve and open, add show The serve command used to serve the files and open the browser. These are now two separate commands: - serve starts a local file server - open opens the browser at the local URL Command show was added to replace the old serve, i.e. drawsm show now serves the files and opens a browser in one command. --- cmd/drawsm/main.go | 49 +++++++++++++++++++++++++++++++------- samples/everything/main.go | 2 +- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/cmd/drawsm/main.go b/cmd/drawsm/main.go index b92df04..4d1a437 100644 --- a/cmd/drawsm/main.go +++ b/cmd/drawsm/main.go @@ -23,10 +23,19 @@ Usage: The commands are: + run + + Does not generate any files in the current directory. Instead it + builds main.wasm in a temporary folder and serves the template + index.html file locally on port 8080. Opens a web browser at the + local URL. + build Generates index.html, wasm_exec.js and main.wasm in the current - project. Serve these files to generate the game website. + project. index.html is only generated if it does not yet exist. + This way you can edit your index.html to extend it beyond the + template. rebuild @@ -34,15 +43,16 @@ The commands are: serve - Serve the files of a build or rebuild locally on port 8080. Opens - a web browser at the local URL. + Serves the files of a build locally on port 8080. - run + open - Does not generate any files in the current directory. Instead it - builds main.wasm in a temporary folder and serves the template - index.html file locally on port 8080. Opens a web browser at the - local URL. + Opens a web browser at the local URL. + + show + + Performs serve and open, i.e. serves the files of a build locally + on port 8080 and opens a web browser at the local URL. help @@ -55,6 +65,10 @@ func main() { check(build(args[0] == "rebuild")) } else if len(args) == 1 && args[0] == "serve" { check(serve()) + } else if len(args) == 1 && args[0] == "open" { + check(open()) + } else if len(args) == 1 && args[0] == "show" { + check(show()) } else if len(args) == 1 && args[0] == "run" { check(run()) } else if len(args) == 0 || len(args) == 1 && args[0] == "help" { @@ -157,6 +171,25 @@ func serve() error { stop <- nil } }() + <-stop + return httpErr +} + +func open() error { + url := "http://localhost:8080" + return openURL(url) +} + +func show() error { + var httpErr error + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + httpErr = http.ListenAndServe(":8080", http.FileServer(http.Dir("."))) + if httpErr != nil { + stop <- nil + } + }() // Wait a short while to let the http server start (or fail) and then open // the localhost URL in the browser. diff --git a/samples/everything/main.go b/samples/everything/main.go index 3d2b828..f547534 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -180,7 +180,7 @@ func main() { for i, k := range lastKeys { lastKeyTexts[i] = k.String() } - text += "Last typed key: " + strings.Join(lastKeyTexts, " ") + "\n" + text += "Last typed keys: " + strings.Join(lastKeyTexts, " ") + "\n" text += "Pressed keys: " + keyDowns + "\n" text += "Pressed mouse buttons: " + mouseDowns + "\n" From 5001726f787dc48ed8eb46d1c6f54990f7b36614 Mon Sep 17 00:00:00 2001 From: gonutz Date: Sat, 21 Jun 2025 23:30:25 +0200 Subject: [PATCH 28/46] WASM: load both images and sounds from embed.FS --- draw/open_file_desktop.go | 14 + draw/open_file_wasm.go | 10 + draw/window.go | 10 +- draw/window_wasm.go | 450 ++++++++++--------------------- go.mod | 2 +- samples/everything/main.go | 35 ++- samples/everything/rsc/meds.png | Bin 0 -> 1380 bytes samples/everything/rsc/music.ogg | Bin 0 -> 2042250 bytes samples/everything/rsc/sound.wav | Bin 0 -> 15930 bytes 9 files changed, 201 insertions(+), 320 deletions(-) create mode 100644 draw/open_file_desktop.go create mode 100644 draw/open_file_wasm.go create mode 100644 samples/everything/rsc/meds.png create mode 100644 samples/everything/rsc/music.ogg create mode 100644 samples/everything/rsc/sound.wav diff --git a/draw/open_file_desktop.go b/draw/open_file_desktop.go new file mode 100644 index 0000000..b7f868d --- /dev/null +++ b/draw/open_file_desktop.go @@ -0,0 +1,14 @@ +//go:build !js +// +build !js + +package draw + +import ( + "io" + "os" +) + +// DefaultOpenFile on desktop loads the file from disk. +var DefaultOpenFile = func(path string) (io.ReadCloser, error) { + return os.Open(path) +} diff --git a/draw/open_file_wasm.go b/draw/open_file_wasm.go new file mode 100644 index 0000000..47088a4 --- /dev/null +++ b/draw/open_file_wasm.go @@ -0,0 +1,10 @@ +//go:build js && wasm +// +build js,wasm + +package draw + +import "io" + +// DefaultOpenFile for WASM builds is nil so that the WASM port knows to load +// from URL. +var DefaultOpenFile func(path string) (io.ReadCloser, error) = nil diff --git a/draw/window.go b/draw/window.go index a825234..0135689 100644 --- a/draw/window.go +++ b/draw/window.go @@ -7,16 +7,14 @@ package draw import ( "io" - "os" "strconv" ) // OpenFile allows you to re-direct from the file system to your own data -// storage for image and sound files. It defaults to os.Open but you can -// overwrite it with any function that fits the signature. -var OpenFile = func(path string) (io.ReadCloser, error) { - return os.Open(path) -} +// storage for image and sound files. It defaults to os.Open on desktop and to +// fetching URLs for WASM. You can overwrite it with any function that fits the +// signature, e.g. to open files from an embed.FS. +var OpenFile func(path string) (io.ReadCloser, error) = DefaultOpenFile // UpdateFunction is used as a callback when creating a window. It is called // at 60Hz and you do all your event handling and drawing in it. diff --git a/draw/window_wasm.go b/draw/window_wasm.go index 9315539..f1bc1d9 100644 --- a/draw/window_wasm.go +++ b/draw/window_wasm.go @@ -5,45 +5,32 @@ package draw import ( "fmt" + "io" "math" "strings" - "sync" "syscall/js" ) type wasmWindow struct { - update UpdateFunction - canvas js.Value - ctx js.Value - width, height int - running bool - keyDown [keyCount]bool - pressedKeys []Key - typedChars []rune - mouseX, mouseY int - mouseDown [mouseButtonCount]bool - wheelX float64 - wheelY float64 - clicks []MouseClick - imageCache map[string]js.Value - imagesLoaded chan struct{} - pendingImages map[string]bool - audioCtx js.Value - audioBuffers map[string]js.Value - closeImagesOnce sync.Once + canvas js.Value + ctx js.Value + width int + height int + running bool + keyDown [keyCount]bool + pressedKeys []Key + typedChars []rune + mouseX int + mouseY int + mouseDown [mouseButtonCount]bool + wheelX float64 + wheelY float64 + clicks []MouseClick + images map[string]js.Value + audioCtx js.Value + audioBuffers map[string]js.Value } -func bindEvent(target js.Value, event string, handler func(js.Value)) js.Func { - jsFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { - handler(args[0]) - return nil - }) - target.Call("addEventListener", event, jsFunc) - return jsFunc -} - -// RunWindow initializes a WebAssembly window with an HTML canvas element, sets -// up input and rendering, and starts the main update loop. func RunWindow(title string, width, height int, update UpdateFunction) error { doc := js.Global().Get("document") doc.Set("title", title) @@ -54,103 +41,95 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { canvas.Set("width", width) canvas.Set("height", height) - ctx := canvas.Call("getContext", "2d") - - // Create the wasmWindow instance with input states, rendering context, and audio - win := &wasmWindow{ - update: update, - canvas: canvas, - ctx: ctx, + window := &wasmWindow{ + running: true, width: width, height: height, - running: true, - imageCache: make(map[string]js.Value), + canvas: canvas, + ctx: canvas.Call("getContext", "2d"), audioCtx: js.Global().Get("AudioContext").New(), - audioBuffers: make(map[string]js.Value), + images: map[string]js.Value{}, + audioBuffers: map[string]js.Value{}, } - win.pendingImages = make(map[string]bool) - win.imagesLoaded = make(chan struct{}) - - // Handles key press events: resumes audio and tracks pressed keys. bindEvent(js.Global(), "keydown", func(e js.Value) { + // In the browser, we might need a user action to be allowed to start + // playing sounds, so we do this in the key and mouse button handlers. + window.startAudioPlayback() + keyCode := e.Get("code").String() keyValue := e.Get("key").String() key := toKey(keyCode, keyValue) - if win.audioCtx.Get("state").String() == "suspended" { - win.audioCtx.Call("resume") + if key != 0 && !window.keyDown[key] { + window.pressedKeys = append(window.pressedKeys, key) } + window.keyDown[key] = true - if key != 0 && !win.keyDown[key] { - win.pressedKeys = append(win.pressedKeys, key) - } - win.keyDown[key] = true - - if win.keyDown[KeyLeftControl] || win.keyDown[KeyRightControl] || - win.keyDown[KeyLeftAlt] || win.keyDown[KeyRightAlt] || + if window.keyDown[KeyLeftControl] || window.keyDown[KeyRightControl] || + window.keyDown[KeyLeftAlt] || window.keyDown[KeyRightAlt] || preventKeyDownDefault[key] { e.Call("preventDefault") } }) - // Handles key release events bindEvent(js.Global(), "keyup", func(e js.Value) { keyCode := e.Get("code").String() keyValue := e.Get("key").String() key := toKey(keyCode, keyValue) if key != 0 { - win.keyDown[key] = false + window.keyDown[key] = false } }) - // Character input (text entry) bindEvent(js.Global(), "keypress", func(e js.Value) { keyStr := e.Get("key").String() if len(keyStr) > 0 { - win.typedChars = append(win.typedChars, rune(keyStr[0])) + window.typedChars = append(window.typedChars, rune(keyStr[0])) } }) - // Mouse movement tracking bindEvent(doc, "mousemove", func(e js.Value) { bounds := canvas.Call("getBoundingClientRect") - win.mouseX = e.Get("clientX").Int() - bounds.Get("left").Int() - win.mouseY = e.Get("clientY").Int() - bounds.Get("top").Int() + window.mouseX = e.Get("clientX").Int() - bounds.Get("left").Int() + window.mouseY = e.Get("clientY").Int() - bounds.Get("top").Int() }) // To determine whether the mouse buttons are currently up or down, we - // register the mouse down and up events on the document. - // To collect mouse clicks, we register the mouse down event on the canvas. - // Clicks outside the canvas are not reported. + // register the mouse down and up events on the *document*. + // To collect mouse clicks, we register the mouse down event on the + // *canvas*. Clicks outside the canvas are not reported. bindEvent(doc, "mousedown", func(e js.Value) { + // In the browser, we might need a user action to be allowed to start + // playing sounds, so we do this in the key and mouse button handlers. + window.startAudioPlayback() + button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { - win.mouseDown[button] = true + window.mouseDown[button] = true } }) bindEvent(doc, "mouseup", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { - win.mouseDown[button] = false + window.mouseDown[button] = false } }) bindEvent(canvas, "mousedown", func(e js.Value) { button := e.Get("button").Int() if 0 <= button && button < int(mouseButtonCount) { - win.clicks = append(win.clicks, MouseClick{ - X: win.mouseX, - Y: win.mouseY, + window.clicks = append(window.clicks, MouseClick{ + X: window.mouseX, + Y: window.mouseY, Button: MouseButton(button), }) } }) - // Mouse wheel bindEvent(canvas, "wheel", func(e js.Value) { - win.wheelX -= e.Get("deltaX").Float() / 100 - win.wheelY -= e.Get("deltaY").Float() / 100 - e.Call("preventDefault") // prevent page scroll + window.wheelX -= e.Get("deltaX").Float() / 100 + window.wheelY -= e.Get("deltaY").Float() / 100 + e.Call("preventDefault") }) // Suppress right clicks triggering the context menu. @@ -158,140 +137,94 @@ func RunWindow(title string, width, height int, update UpdateFunction) error { e.Call("preventDefault") }) - // Main render loop using requestAnimationFrame + // Main render loop using requestAnimationFrame. var renderFrame js.Func renderFrame = js.FuncOf(func(this js.Value, args []js.Value) interface{} { - win.FillRect(0, 0, win.width, win.height, Black) - if win.running { - win.update(win) + window.FillRect(0, 0, window.width, window.height, Black) + if window.running { + update(window) // Reset input state between frames. - win.wheelX = 0 - win.wheelY = 0 - win.clicks = win.clicks[:0] - win.pressedKeys = win.pressedKeys[:0] - win.typedChars = win.typedChars[:0] + window.wheelX = 0 + window.wheelY = 0 + window.clicks = window.clicks[:0] + window.pressedKeys = window.pressedKeys[:0] + window.typedChars = window.typedChars[:0] } js.Global().Call("requestAnimationFrame", renderFrame) return nil }) js.Global().Call("requestAnimationFrame", renderFrame) - // Prevent Go main from exiting (WASM requires this to keep running) + // WASM requires us to prevent main from exiting. select {} } -// setColor sets both fill and stroke styles on the canvas context -// based on the provided RGBA color. Each color component is converted -// to its 0–255 representation for use with CSS-style RGBA strings. +func bindEvent(target js.Value, event string, handler func(js.Value)) js.Func { + jsFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + handler(args[0]) + return nil + }) + target.Call("addEventListener", event, jsFunc) + return jsFunc +} + +func (w *wasmWindow) startAudioPlayback() { + if w.audioCtx.Get("state").String() == "suspended" { + w.audioCtx.Call("resume") + } +} + func (w *wasmWindow) setColor(c Color) { r := int(c.R * 255) g := int(c.G * 255) b := int(c.B * 255) a := c.A - w.ctx.Set("fillStyle", fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a)) - w.ctx.Set("strokeStyle", fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a)) + // We use CSS-style RGBA strings. + col := fmt.Sprintf("rgba(%d,%d,%d,%f)", r, g, b, a) + w.ctx.Set("fillStyle", col) + w.ctx.Set("strokeStyle", col) } -// loadImage loads an image from the given path and returns the corresponding -// JavaScript image element. The result is cached to avoid redundant network requests. -// -// The function sets up onload and onerror callbacks to resolve a Go channel -// once the image is successfully loaded or has failed to load. func (w *wasmWindow) loadImage(path string) (js.Value, error) { - if img, ok := w.imageCache[path]; ok && img.Truthy() { + if img, ok := w.images[path]; ok && img.Truthy() { return img, nil } - if _, loading := w.pendingImages[path]; loading { - return js.Null(), fmt.Errorf("image still loading: %s", path) - } - - w.pendingImages[path] = true - img := js.Global().Get("Image").New() - var onLoadFunc, onErrorFunc js.Func - - cleanup := func() { - delete(w.pendingImages, path) - if len(w.pendingImages) == 0 { - w.closeImagesOnce.Do(func() { close(w.imagesLoaded) }) + if OpenFile != nil { + url, err := loadBlob(path) + if err != nil { + return js.Null(), err } + img.Set("src", url) + } else { + img.Set("src", path) } - // Allocate and bind onload handler - onLoadFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { - onLoadFunc.Release() - onErrorFunc.Release() - - w.imageCache[path] = img - cleanup() - return nil - }) - - // Allocate and bind onerror handler - onErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} { - onLoadFunc.Release() - onErrorFunc.Release() - - cleanup() - return nil - }) - - img.Set("onload", onLoadFunc) - img.Set("onerror", onErrorFunc) - img.Set("src", path) - - return js.Null(), fmt.Errorf("image still loading: %s", path) - + w.images[path] = img + return img, nil } -// loadSoundFile fetches and decodes an audio file from the given path using the Web Audio API. -// It returns a decoded AudioBuffer that can be played via PlaySoundFile. -// -// The result is cached in audioBuffers to avoid redundant decoding on repeated calls. -// This function blocks using a channel until the asynchronous JS fetch and decode are complete. -func (w *wasmWindow) loadSoundFile(path string) (js.Value, error) { - // Return cached buffer if already loaded - if buffer, ok := w.audioBuffers[path]; ok { - return buffer, nil +func loadBlob(path string) (js.Value, error) { + f, err := OpenFile(path) + if err != nil { + return js.Null(), err } + defer f.Close() - done := make(chan struct{}) - var result js.Value - var err error + data, err := io.ReadAll(f) + if err != nil { + return js.Null(), err + } - fetchPromise := js.Global().Call("fetch", path) - then := js.FuncOf(func(this js.Value, args []js.Value) interface{} { - resp := args[0] - resp.Call("arrayBuffer").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} { - arrayBuffer := args[0] + array := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(array, data) - // Decode the ArrayBuffer into an AudioBuffer using decodeAudioData - w.audioCtx.Call("decodeAudioData", arrayBuffer, - // Success callback - js.FuncOf(func(this js.Value, args []js.Value) interface{} { - result = args[0] - w.audioBuffers[path] = result - close(done) - return nil - }), - // Error callback - js.FuncOf(func(this js.Value, args []js.Value) interface{} { - err = fmt.Errorf("failed to decode audio: %s", path) - close(done) - return nil - }), - ) - return nil - })) - return nil - }) - - fetchPromise.Call("then", then) - <-done + blob := js.Global().Get("Blob").New([]interface{}{array}) + url := js.Global().Get("URL").Call("createObjectURL", blob) - return result, err + return url, nil } var keyMap = map[string]Key{ @@ -397,52 +330,6 @@ var keyMap = map[string]Key{ } var preventKeyDownDefault = map[Key]bool{ - KeyA: false, - KeyB: false, - KeyC: false, - KeyD: false, - KeyE: false, - KeyF: false, - KeyG: false, - KeyH: false, - KeyI: false, - KeyJ: false, - KeyK: false, - KeyL: false, - KeyM: false, - KeyN: false, - KeyO: false, - KeyP: false, - KeyQ: false, - KeyR: false, - KeyS: false, - KeyT: false, - KeyU: false, - KeyV: false, - KeyW: false, - KeyX: false, - KeyY: false, - KeyZ: false, - Key0: false, - Key1: false, - Key2: false, - Key3: false, - Key4: false, - Key5: false, - Key6: false, - Key7: false, - Key8: false, - Key9: false, - KeyNum0: false, - KeyNum1: false, - KeyNum2: false, - KeyNum3: false, - KeyNum4: false, - KeyNum5: false, - KeyNum6: false, - KeyNum7: false, - KeyNum8: false, - KeyNum9: false, KeyF1: true, KeyF2: true, KeyF3: true, @@ -467,32 +354,16 @@ var preventKeyDownDefault = map[Key]bool{ KeyF22: true, KeyF23: true, KeyF24: true, - KeyEnter: false, - KeyNumEnter: false, KeyLeftControl: true, KeyRightControl: true, KeyLeftShift: true, KeyRightShift: true, KeyLeftAlt: true, KeyRightAlt: true, - KeyLeft: false, - KeyRight: false, - KeyUp: false, - KeyDown: false, - KeyEscape: false, - KeySpace: false, - KeyBackspace: false, - KeyTab: false, KeyHome: true, KeyEnd: true, KeyPageDown: true, KeyPageUp: true, - KeyDelete: false, - KeyInsert: false, - KeyNumAdd: false, - KeyNumSubtract: false, - KeyNumMultiply: false, - KeyNumDivide: false, KeyCapslock: true, KeyPrint: true, KeyPause: true, @@ -530,7 +401,7 @@ func isUpperCaseLetter(s string) bool { func (w *wasmWindow) Close() { w.running = false - // TODO Stop all sounds. + w.audioCtx.Call("close") } func (w *wasmWindow) Size() (int, int) { @@ -556,8 +427,6 @@ func (w *wasmWindow) ShowCursor(show bool) { } } -// WasKeyPressed returns true if the given key was pressed during this frame. -// Use this for single-trigger events (e.g., jumping, opening menus). func (w *wasmWindow) WasKeyPressed(key Key) bool { for _, k := range w.pressedKeys { if k == key { @@ -567,52 +436,39 @@ func (w *wasmWindow) WasKeyPressed(key Key) bool { return false } -// IsKeyDown returns true if the given key is currently held down. -// Use this for continuous input (e.g., holding movement keys). func (w *wasmWindow) IsKeyDown(key Key) bool { return w.keyDown[key] } -// Characters returns a string of characters typed by the user during this frame. -// Useful for text input fields or typing games. func (w *wasmWindow) Characters() string { return string(w.typedChars) } -// IsMouseDown returns true if the specified mouse button is currently pressed. func (w *wasmWindow) IsMouseDown(button MouseButton) bool { return w.mouseDown[button] } -// Clicks returns a slice of all mouse clicks registered during this frame. -// Each MouseClick contains the position and button. -// The slice is cleared after each update. func (w *wasmWindow) Clicks() []MouseClick { return w.clicks } -// MousePosition returns the current mouse cursor position relative to the canvas. func (w *wasmWindow) MousePosition() (int, int) { return w.mouseX, w.mouseY } -// MouseWheelX returns the accumulated horizontal scroll value for the current frame. func (w *wasmWindow) MouseWheelX() float64 { return w.wheelX } -// MouseWheelY returns the accumulated vertical scroll value for the current frame. func (w *wasmWindow) MouseWheelY() float64 { return w.wheelY } -// DrawPoint renders a single pixel (1x1 rectangle) at (x, y) using the specified color. func (w *wasmWindow) DrawPoint(x, y int, c Color) { w.setColor(c) w.ctx.Call("fillRect", x, y, 1, 1) } -// DrawLine renders a straight line between (x1, y1) and (x2, y2) with the given color. func (w *wasmWindow) DrawLine(x1, y1, x2, y2 int, c Color) { w.setColor(c) @@ -659,7 +515,6 @@ func abs(x int) int { return x } -// DrawRect outlines a rectangle using stroke style at the given position and size. func (w *wasmWindow) DrawRect(x, y, width, height int, c Color) { if height == 1 { w.DrawLine(x, y, x+width, y, c) @@ -671,7 +526,6 @@ func (w *wasmWindow) DrawRect(x, y, width, height int, c Color) { } } -// FillRect renders a solid filled rectangle. func (w *wasmWindow) FillRect(x, y, width, height int, c Color) { if width <= 0 || height <= 0 { return @@ -681,7 +535,6 @@ func (w *wasmWindow) FillRect(x, y, width, height int, c Color) { w.ctx.Call("fillRect", x, y, width, height) } -// DrawEllipse draws an outlined ellipse within the bounding rectangle at (x, y, width, height). func (w *wasmWindow) DrawEllipse(x, y, width, height int, color Color) { if width <= 0 || height <= 0 { return @@ -698,7 +551,6 @@ func (w *wasmWindow) DrawEllipse(x, y, width, height int, color Color) { } } -// FillEllipse draws a filled ellipse within the bounding rectangle. func (w *wasmWindow) FillEllipse(x, y, width, height int, color Color) { if width <= 0 || height <= 0 { return @@ -717,8 +569,6 @@ func (w *wasmWindow) FillEllipse(x, y, width, height int, color Color) { } } -// ImageSize returns the native width and height of the image at the given path. -// The image is loaded (or retrieved from cache) if needed. func (w *wasmWindow) ImageSize(path string) (int, int, error) { img, err := w.loadImage(path) if err != nil { @@ -727,43 +577,35 @@ func (w *wasmWindow) ImageSize(path string) (int, int, error) { return img.Get("width").Int(), img.Get("height").Int(), nil } -// DrawImageFile draws an image at the given position. -// If the image is not loaded yet, nothing is drawn. func (w *wasmWindow) DrawImageFile(path string, x, y int) error { img, err := w.loadImage(path) - if err != nil || !img.Truthy() { - return nil + if err != nil { + return err } w.ctx.Call("drawImage", img, x, y) return nil } -// DrawImageFileTo draws an image scaled to a new size and rotated (in degrees) around its center. func (w *wasmWindow) DrawImageFileTo(path string, x, y, w2, h2, rot int) error { img, err := w.loadImage(path) if err != nil { return err } - // Save current context w.ctx.Call("save") - // Translate to center of target rect w.ctx.Call("translate", x+w2/2, y+h2/2) w.ctx.Call("rotate", float64(rot)*math.Pi/180) - // Draw centered image w.ctx.Call("drawImage", img, - 0, 0, img.Get("width").Int(), img.Get("height").Int(), // source - -w2/2, -h2/2, w2, h2, // destination (centered) + 0, 0, img.Get("width").Int(), img.Get("height").Int(), + -w2/2, -h2/2, w2, h2, ) - // Restore context w.ctx.Call("restore") return nil } -// DrawImageFileRotated draws the image at (x, y), rotated by `rot` degrees about its center. func (w *wasmWindow) DrawImageFileRotated(path string, x, y, rot int) error { img, err := w.loadImage(path) if err != nil { @@ -781,8 +623,6 @@ func (w *wasmWindow) DrawImageFileRotated(path string, x, y, rot int) error { return nil } -// DrawImageFilePart draws a subsection of the image, defined by source rect (sx, sy, sw, sh), -// to a destination rect (dx, dy, dw, dh) and applies rotation (degrees) around its center. func (w *wasmWindow) DrawImageFilePart(path string, sx, sy, sw, sh, dx, dy, dw, dh, rot int, ) error { @@ -796,8 +636,8 @@ func (w *wasmWindow) DrawImageFilePart(path string, w.ctx.Call("rotate", float64(rot)*math.Pi/180) w.ctx.Call("drawImage", img, - sx, sy, sw, sh, // source rect - -dw/2, -dh/2, dw, dh, // destination rect, centered + sx, sy, sw, sh, + -dw/2, -dh/2, dw, dh, ) w.ctx.Call("restore") return nil @@ -811,13 +651,10 @@ func (w *wasmWindow) BlurText(blur bool) { // TODO Figure out how we want to draw and blur text. } -// GetTextSize returns the width and height (in pixels) required to render the given text at default scale. func (w *wasmWindow) GetTextSize(text string) (int, int) { return w.GetScaledTextSize(text, 1.0) } -// GetScaledTextSize returns the pixel dimensions required to render text at the given scale. -// Line breaks are taken into account. func (w *wasmWindow) GetScaledTextSize(text string, scale float32) (wOut, hOut int) { if scale <= 0 { return 0, 0 @@ -839,13 +676,10 @@ func (w *wasmWindow) GetScaledTextSize(text string, scale float32) (wOut, hOut i return maxWidth, int(0.2*lineHeight + lineHeight*float64(len(lines)) + 0.5) } -// DrawText renders a string at (x, y) using the given color and default scale (1.0). func (w *wasmWindow) DrawText(text string, x, y int, color Color) { w.DrawScaledText(text, x, y, 1.0, color) } -// DrawScaledText renders a string of text at the given position with a scaling factor and color. -// Text is drawn using a monospace font, and supports multi-line input (lines split by '\n'). func (w *wasmWindow) DrawScaledText(text string, x, y int, scale float32, color Color) { if scale <= 0 { return @@ -853,50 +687,61 @@ func (w *wasmWindow) DrawScaledText(text string, x, y int, scale float32, color w.setColor(color) - // Compute font size based on scaling factor - fontSize := 16.0 * float64(scale) // base size of 16, feel free to tweak + // TODO For now we use base size 16, might need tweaking. + fontSize := 16.0 * float64(scale) - // Apply font style to canvas context (monospace font for uniform spacing) w.ctx.Set("font", fmt.Sprintf("%.2fpx monospace", fontSize)) - // Split the input into lines lines := strings.Split(text, "\n") - // Define line spacing as 1.2x font size + // Define line spacing as 1.2 times the font size. lineHeight := fontSize * 1.2 - // Draw each line at its vertical offset + w.ctx.Set("imageSmoothingEnabled", false) for i, line := range lines { w.ctx.Call("fillText", line, x, fontSize+float64(y)+float64(i)*lineHeight) } } -// PlaySoundFile plays an audio file by path using the Web Audio API. -// It ensures the AudioContext is resumed before playback, as required by browser policies. func (w *wasmWindow) PlaySoundFile(path string) error { - // Do not wait on resume or fetch — just try it - if w.audioCtx.Get("state").String() == "suspended" { - w.audioCtx.Call("resume") - } + // Sounds might not have been started yet. + w.startAudioPlayback() - // Already loaded? Play immediately if buffer, ok := w.audioBuffers[path]; ok { return w.playBuffer(buffer) } - // Begin async load - w.asyncLoadSound(path, func(buffer js.Value, err error) { + if OpenFile != nil { + url, err := loadBlob(path) + if err != nil { + return err + } + w.loadAndPlaySound(path, url) + } else { + w.loadAndPlaySound(path, path) + } + + return nil +} + +func (w *wasmWindow) playBuffer(buffer js.Value) error { + source := w.audioCtx.Call("createBufferSource") + source.Set("buffer", buffer) + source.Call("connect", w.audioCtx.Get("destination")) + source.Call("start") + return nil +} + +func (w *wasmWindow) loadAndPlaySound(path string, url interface{}) { + w.asyncLoadSound(path, url, func(buffer js.Value, err error) { if err == nil { w.playBuffer(buffer) } }) - - return nil } -// Non-blocking async sound load using JS promises -func (w *wasmWindow) asyncLoadSound(path string, callback func(js.Value, error)) { - fetchPromise := js.Global().Call("fetch", path) +func (w *wasmWindow) asyncLoadSound(path string, url interface{}, callback func(js.Value, error)) { + fetchPromise := js.Global().Call("fetch", url) fetchPromise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} { resp := args[0] resp.Call("arrayBuffer").Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} { @@ -918,12 +763,3 @@ func (w *wasmWindow) asyncLoadSound(path string, callback func(js.Value, error)) return nil })) } - -// Small helper to play a buffer -func (w *wasmWindow) playBuffer(buffer js.Value) error { - source := w.audioCtx.Call("createBufferSource") - source.Set("buffer", buffer) - source.Call("connect", w.audioCtx.Get("destination")) - source.Call("start") - return nil -} diff --git a/go.mod b/go.mod index 5528607..a66c90a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gonutz/prototype -go 1.11 +go 1.16 require ( github.com/gonutz/d3d9 v1.2.1 diff --git a/samples/everything/main.go b/samples/everything/main.go index f547534..74d8ce9 100644 --- a/samples/everything/main.go +++ b/samples/everything/main.go @@ -1,13 +1,28 @@ package main import ( + "embed" "fmt" + "io" "strings" "github.com/gonutz/prototype/draw" ) +//go:embed rsc/* +var rsc embed.FS + +// Toggle loadFromEmbed to load from disk/URL (false) or from the embedded file +// system (true) +const loadFromEmbed = true + func main() { + if loadFromEmbed { + draw.OpenFile = func(path string) (io.ReadCloser, error) { + return rsc.Open(path) + } + } + var ( fullscreen bool blurImages bool @@ -75,6 +90,9 @@ func main() { wheelY += window.MouseWheelY() textScale += float32(window.MouseWheelY() / 10) + if textScale < 0.1 { + textScale = 0.1 + } if window.WasKeyPressed(draw.KeyF) { fullscreen = !fullscreen @@ -87,7 +105,11 @@ func main() { } if window.WasKeyPressed(draw.KeyS) { - window.PlaySoundFile("sound.wav") + window.PlaySoundFile("rsc/sound.wav") + } + + if window.WasKeyPressed(draw.KeyM) { + window.PlaySoundFile("rsc/music.ogg") } mx, my := window.MousePosition() @@ -158,12 +180,12 @@ func main() { window.DrawLine(210, 445, 212, 447, draw.LightBlue) window.DrawLine(210, 450, 211, 451, draw.LightGreen) - imgW, imgH, _ := window.ImageSize("meds.png") + imgW, imgH, _ := window.ImageSize("rsc/meds.png") window.FillRect(9, 519, imgW+2, imgH+2, draw.DarkYellow) - window.DrawImageFile("meds.png", 10, 520) - window.DrawImageFilePart("meds.png", 32, 0, 16, 15, 100, 520, 3*16, 3*15, 45) - window.DrawImageFileRotated("meds.png", 200, 520, -20) - window.DrawImageFileTo("meds.png", 300, 520, 128, 64, 5) + window.DrawImageFile("rsc/meds.png", 10, 520) + window.DrawImageFilePart("rsc/meds.png", 32, 0, 16, 15, 100, 520, 3*16, 3*15, 45) + window.DrawImageFileRotated("rsc/meds.png", 200, 520, -20) + window.DrawImageFileTo("rsc/meds.png", 300, 520, 128, 64, 5) windowW, windowH := window.Size() @@ -174,6 +196,7 @@ func main() { text += "I: Blur Images (" + boolToString(blurImages) + ")\n" text += "T: Blur Text (" + boolToString(blurText) + ")\n" text += "S: Play Sound\n" + text += "M: Play Music\n" text += "Text written so far: " + characters + "\n" lastKeyTexts := make([]string, len(lastKeys)) diff --git a/samples/everything/rsc/meds.png b/samples/everything/rsc/meds.png new file mode 100644 index 0000000000000000000000000000000000000000..ed79f2a51ab2084ec97d0d09e263cce07ccfa941 GIT binary patch literal 1380 zcmV-q1)KVbP) Px)9!W$&RCt{2n@?z5MI6V!FBXgm>p`RnSwx{}K&ZL&;4!+3xfHsG&`aVP#Y;*L zO~6BG@F0tOC|D2Dg5bY4y~HAmRxt3O)r*&w{t*IcgG#eOtQatXY=qkJ@OIwLo1K|A z^LA!-L;8X6-kbO4&HR4zd%yYpW+wqS2qA Z|&YwS5(_0%*gPQy1c!Uw`v$;^AVkWZVf}AXR<(?Brq&%Xc=hT_46k{vNjL z! FMJlLKw((JP93 mJ{zwQ@jY1{-_W- zK~{JGSj+|5rW*_2cUUOA?D@&XT%vsWPE?P6|11Ofd)E9xU)lq&J*EGCR``v<8~Fa+ z_EF_WUj=o7Ec|t+2%X@ut=8zD-;#y}33%bD1+AYcW9YqTW>E?M_A?g~*E u1VFBfnDz`roO<*9Sj&OG}t6 zq<#V>h=d2sooIg^*!g@uzP@|nD|?;f5Pkwjrc2G^<-{=yy25|4@n+(Bd36)p KI2kgW`LItq10Cj@L6uy9sA!e92nvOgV8wIjuHjzexl&cM*H4}&eDIMYE z0T`{eDlE4Z2S@`U9^D2_5+7MT9h^mivd;KQg?%Hm0O`ZKObI&H=w&^i8|bFtLMJ#n zGSOCcdnNl+=o>2hwS}{I`20(F<;qXe+##U=RZ5)2>t=O`K54T6K1HQDc^2VySAtwI zHZ==`HSGWRBeRO6Nuh9*o*GS2eBH)c>)Na**`Vz|@yD-(5JC=p_oKA^HVfcKj-Km| zAio&G;$lrtMJ5a2M~}TWsoPj<{S^vFNn?$Wx#JhTSORMSf0s&13y@}mrb|>Zj!c(Q z(`i?XP0a#;(gLIk2>T8LZ&aecjC50Y0Jyer769 *!N_q-*rr;zh0$d8FsVqlr$N7c0t6~=aU)=v*kWFNcc*m5`j=0 z$Nz8(;b+eweEr|}NaJ`HosdBtuuEbEu)~JSv=g0l0KO>Wn8Pu ?v va;0*xL zZ yF!3S0ED7VW-JjG=k3Q32o%wy&tt>sJ$FX8gSwquaFz1dZ7+vQj ztJ`fQSes$_UxU+jiO*N^)bgG)7h`qyu9`ODYWl=XaX zmXKl12b?1>;@q=L!J%>G^F>4Nz3DFLRFLlO4Tu8Lozg8KCG7@j0ZD13r5ou`QaYpr1Ze>Y={`65 zd!F-r@AqEkeAjiZ@4qw79w*k!x@X1AS~G)+wY3HS1^x+rE`Kw4znBmZYKVuEi 10i&6oozT&T&!uI+nQ u60Y>*!(Y;^gRH>FCCMH!=zM({QqKdui%oDgIcI zR>RHR!q(}oCm#g)@9@mhGTIOT4Fu#?r0qtG#%KWm9srooGvmZt$us9CWHWgsC&=C9 zy1BxW6T-Vm%_F&c{*}=3S=<8vBmlvL9-X@`Z#OJ#P0ASUk|k`bEcAgEH9~Dk82$6{ zLvzQ1+5&sWVP*z2Y&~R lu!KS1k|HL!D#nZd^XX>;NAnSqfyA|e~`{X&x!a1;@kWwgM zaYqtb^tk)Xak=sZR(9pKX&-G$J~|9kvkg>Z{Q&_vk97AnK$dN4!T&2->Akc3-$%Gj z9}55nY1#XNviAkOtU7(K3mfVm3ikmZPvPop-7b7jT={xk#lZN)?a`M6#i1ib+ z-|_wti=t=g!=HqIfIY&$rP(*48-clVi}c+g^t-zMDn4+KZ>BS(JsE+te$gmqM05po z@zic_apJSM`x*lFpK2wO@FN{jsBOx|I(ZnM7^<;Ut;N>R?{nuvkm>` zQ#s@tBa~I+Q_{F+G EM-x^MO0b|9SXtaH;V_RQ>}OoKFd^sq}x2>Hm)Ke<|?4r2r`6 z5FGGFES*iq3PlJ(fY23q9IgC5AHrO3U(AzSk#I?uSPVB6b>d&z0dBCr`8=t`a0}35 z_;52(M_$|h+js KZ_1S;005yl3q8=jkc+c~00>LHOjH1fv-G0=?}z#SUi)7VLJ&*<5SpV) z>`XZTjW*YV$FX6W=mAPFPjWaB )pAXF9XKr{EppP7a^Ja?3v4w)$3rKe4JF*MdD6rwE+Vm*^wDds7 z521H!UO7w=p#4ia+#C<~FM3f%cv<^<9h*`|upaz>MYFp7}6{ZiRw~v`FZL*3heps)RE~D z;}nW$^VHRukpTpi5ctzY3l1qo0)B(mo_T`VpzsF0v;y~fx+?))J}GG46cL%<**FPt z+@PJTtneintYgf#0O8W()kVNM9MIr!13~f<)TKAmT|xUJ0|bEp@W0fKc&^Mvp!tW~ z_kfP2J9cF 1npM4xV*nLsbVAlP~R)_9~!$Df!w$w6vMzBkBW+mF~HIU zNT}dDd$+iF`}4;;`cJ!?&fO7zyQu@DySpV>!`=4Hh6w;F&H%ttY3d#npHtNVZgkMQ z{W6FfjTyALQ0VgTq~(}{WB#Aw4?%!^5P1K1C<2kD|Kwg^{HNFQKmC{gKXWts_GNY2 z0O@cr46YXx#al|BaW5K$guI#nKtl()E)~YTNuLm>pm0w*QkaP$@tuA|nRFO8T7KSF z{pZ}oBf_ZJc?tRv%paFQ27z_pRP+ro>GBiw->`oqO?yrUa?KvBGv`&P1M9%4*t01# zf^}8hr1nv=Olbypt@dU7%xkVst0wi$OY0uyM#kOMmDWw&Pf+LSA<_VIsX<&lr=;ek z+#CAw1fS2SHP}I5dr%f0SVR@|=!1fTZW$THKAGS;j10iVg#b7MsT3ob7O`nAItex6 z4`UiZ9}*P3o;wM H+V2`^75FJDQ z*S|fO?kr)2qJKB!CnTu<>5KNqL9i#DQ@6|1^Mz!14cS01<)1{Oti|?o9B+JJ`F-pS0*M2NwUPe;e{aL}2CLsyjqr z@t^VjAWBeY`C9~+U)bI1_ke}HOPRM^4EltKM-l! R4v1+-{NFPlVSrrF%)iB|8uR zVE8 Kh2`R834$sBf`RL)`djGB;eAI9?K~xDyxFa8|2SU z2nQf&Xn!5fyD}Qu-@}j&hCls*yTg#XZShl(*aKP}U3o=4gWJU0uv>@Qh}#eadHJUb zPYg};jBb-}qi#dp1b$KgltGlINAgy*n%ABsmnlywp6&R36r4j-)F@A$Yj2jkHPDKa zH3+cOy1Dw|df@)WoK;<0y>!yMfBbO0x-xf72<9~uJWBAfJOM6+Sh&}Nri; pK@%?c?`@% zm;v8=s4J63&RgqpM;*!+C6$Pj@vkd<{HrS}QKPg6)JiMM)MRBKV-`d=$PcSb7y2*~ z><**~xURcf`hy}uH92xPNG0L-a)}UAGgM-(tFfo->9wl ZcNc{)OmY$MshTV@SX2KJhnKeJe5FMhU^H9%p zjdgsX&?|zmYVfUtUWNaIi?dkCS0^=ZAE@N8TH;sDG!|$lg>OW^OsE!BSn{alUew8& zvs+6=k{kJLj-2rLfJ&3aX Eqz03^_9~kYwo{%e}Ajr39YY)_HT?lWn)FZy%O`MuQD{*H;wO*I?iUV1TMne zcgU9!0vN+CjM{H}6yIk-uI`);$0?T#-0^_-Y4dMCI+Wly^5n;LA_LB;_Bc{vx*Bbj zO=(nk9&|6(IMaS&SKYMe*3_kp#jv(lnCY+g#-5FO2sPhtJ9tLK-j|SA>fh=blJNCA zvk?)+;csttUMsPKk0X-C6$1l3GkGvv@5GrV?^Ma_vL$wXLBg(-ogZ3>F~7c0knJ`s z00X5vV>WR@y8PFMC!-r%yhppVDDpGL)I6du5P=pV?Uz^WoY*4 NhWqIJQ%xnb-QqG(p_GTFy| zN{f29u(&;}(0MesQd-NOLx+jMGaP{7Tb-->(+y8LZ8$X^Ig;kk7_|+vS 0nfZMx_LDNd3dCE$Lyyo zxCo!)x4Z&*g2?3q*Bzn#Kts>6bDNT}Y&I4`E_A?z1f?rT%=x9rCSUK?vtJ&u@5h)W zJ-;`@fS69*%bt{v#i$G$DzhWa*Jh28JqO~rPb#vujL5lb`H`^&zvl+7urvAC?E5DY zMGHnU(U&P}gc}LiG+w`|MBfYiEi4#Q0x^D2o5Ve&ym7m)X5UBFJMA<*yJi+li_?B+ zV-U}lvXE^(>#wW5lAUeOE=MlX=yQAY!|mBTW+iLda?}lWD~;>NsaMnO)Tmj5jVkXs zzRI; $OBf@rC4tZ-bI2=X2s*+X4?!IR2;8SdyW(%^X!$Z-@MC7# zoD`-DW8sFU!mJp9OmFck@lq596U!r!3mqZshaEH^6P8}rP+`AFI4eG<^uraR*lDw` zboRjEh5;BSr$8FI*7J!l=~#)HoGI?H5mZE1K1@%DSvu$ dQsS& zPnFBO*KvNwS}RB8UaaP~-PTM>)NIxyZ@7q^qZ(+Vy?}Aua2I}=pmaNr%YccCwO7!& zid<@a?Oa%Eu(p3^MnK9iXMt~3aw$Hlxc}*RW&Rgie5UDiOe&rBc^OB!Q%0%A=}Y9> z=dJ09imkluM)6(|_!gND{Lgq_yik&*;kxj8V|~M0bE9aic3ifvM?MX zI^fIJquxp0adiBK4TsrTtr&HAYjM%25b4WkJl`OaZ)6H0g?&W=fU1gjWJ9swyW?uj znr;Lbh`tzvA9y$}VJF@#{wN@h*6eH3woV~n@ObI^?%ajaJs1LX?3U%4n({BWGBXoe zDBywRpB wAt=#zBK zHi6AR`>a}@zN1Wu!^4{C@)0s}0Uz(hks k}nXtv$CidY$rP18MoY zZv0Wv8 *()KkYqQrJ9y}@9+mrJ}O=`wFF_M@hUJ`yV8DDui2T)`Aa z{eEP?Mo85ivasROk7uPneObi&di5GWJClYcD v@2sZpX`cAb;Ou!$)VDiT@D8R#<8N9Cr&h&u%084X;DON@!|Y~*Bi=U#a)hhBJFtndA>d?ls7RFZ!G)y3JOAmI-C=?L4# zS6K7&Q}Xm@PK0t@jW2X^r#&EV4nOD90Ic@hfGmA!9^v8ol##T(WILze>9Vg&(#Z(H zM5|NOl)2rznb{KHp LyN6*X5R1q@pI;a;-)M;$JyyPs z^PM{N2sCY(!*k#A61<`cntaGtWv@hT0?hRqeTmm>DQ_C*y~<8J%OqU5NuV-qJ2A$h zueSU6sJ}dI)ajq&NND10)O@GHA=SLRCv3BP&Z2SBwNT-W-!`#bM`_^cmZnuD#o@cK zIQbKedC*Kgp dmgWNb2R2BzZh~PA 4J^Gta<2AF;N5Qjt>d872p$H#OJa$h%=*Hg%$KXR-0@%FZ} z)pQ=(^L;A=vBk##ItUn0=EF;Hs~S*OUx=4#De_DhXeUu~1M5k?P%#?_#Kv3mkx-fK z#Go13K!Y7J5VqD7fkLY zrAUICNCz&kWQIfB6VKZQ9&RZ2`2oI=aNl+WgMNK9vt1!FLkcqi(2;sU!GR8Slx&La zfC1;TNc5`&zjc9VYheE+IqkG0^81&)SM1D4Z$I#1y_yXSH9V$xOb_`|pqZ0JHt0D^ z6jMSCf3{x;Xt3{5^I5S*J$yS#P}wyzwq4uQ9$_t`g>yBZoLK%dAvOM9ofV)O+HYUs zW0IEqIs*Gq9l=96(qqa!24oRKKD}~zNa1k(NBspp;LL8~@ti);kPDHpz%{OOZy8~W zA5USG`vO73`+jTpxsIaFB}Rw7aLm39S3D&i{`BfXg~mDyS(QW-&`;mSLY~D_0OCSr z^9U`R-SKI?Q-2lepd$L2h-F@PZ$Afs$6ery1U>#Ojc!GTTpihxLcURWzOA_|aR(PR zD-_sg+l3H>(Kt(WmlFe&I2a<7s-7S@#ZWL16i9GUWV8q|HNjd {^;F{t}3rmx<>COVgXZ>_W!8umsO&y5H7l z0$kEeOWYkO9hG4lZ!3}E`$Pa7K)#;}HL)Q@dqV*Ido%-A1=$9MyQ7&q^H#Qw7>(>U z;nwr^&28#q1w&oq+k)G8@SpFk`>ne~TI*MXFsM24gY+jAZ`x2f>DgoX4(_ZmaDCgJ z#Ei`O)bfM}R3%R@m&vT&y|hgv$4ByY#WFm o9}f@e8SXWQ|XN*yb6E#npTv@;(twx$tf<_xGA<^Xxd;ryeKe!HFub z>^Haf%10fjWp~(~q6F&4F7$3103Ku|CZ=lHj)l45UvO~xbIGDPUzP5Ehf1*6$mwc- zsE_<^g?0WBQyD(pH;s;?{8}tZUI=JavT_h38!z_mCXqxjQL(DP;JRF0XnaMpJaK^L z<5WrI6N*|W41~R)seE@kud8ZS0`Ba)pj7I?-S2_-x6TaTt#z{0SqWkH=ey%neqc1Gy#6 z@8fY Qzy)yWSf(KvZr+iJQF{o;L2jqHr-8DU8arP&`j z4o^H4YRMW)cf><|;PkZi%^Wt4a_sTBH*cmuj%dhLm)SBKa+Bc31Iw;-b|Li-N+B5Q z1NAxuzZ=0P6pQDaIj=omP|+~S89u1MER;(Z77S)Vtgm0v%t4smMq1TRz)vlaDH?uW zw%$%ZL^|5Py8RBo7m(poBAsh#W)f*MwjsPN?IT)heA69=IJ?Dm1aRB|E`(z`ADm>? zG}aRPD54noN#ErJraTNQlzP*w)FidASfKj~x!JTy)m89=DIefQa{o~zD*eiu_s5SG z>1Xfs(w?#z53+F#?$1JK1<}i0=iqd*_H#0M@X*a_S6fQ7cfS;erE@PJnC`6M%5C z%z(G}OQ;=aNZ2q48Wf;zZk@8;#38qu&z;tIrv8jpu0ZeTP7%&V({Ck2#R Qb!y z4#eywe1WL{v|$bB^_v^TD`T vZuz_1@yv z69J%-Ged^SS&~48k7|(yyGvr<9-#f;v#eX~PIGgRGQi=ocX~Se?3KsZwd4*g7J%5D z(5nWs^27&PfBwD2^b9xLcc9FnUK_Pm{xun%QK3%%>}R~;rkc|pMzZ_yM&=pRYX+o| z3-0fKve&0!U6m}C#GC4&ChvT%KHAoAcwpT83N=eQ-9vahAbIG4UkyfT5u~kEULa%C z$6y}@>xic>cwUDWz>ETyAU(k(?n%ZazA&f7>x#peN9%%M0RSy0ki`xV7cUI4uR)~E zeyz6FD(|F@0>BN(2S5@)pn1Yi*UAo{M>K6v0ks;AZHi*5E%k?q$PnPU*UHNXybba& z^tyUj2c{g?JY9#A$t$ }bA6l^bSjfx #`fzgzhHxo+u8XxaqV@ zKsbtZ>ixgflvK>p_m;u>AIbyQH#mT5+jLfQw#jVPT1-#p+tUh3+^LLqZ#j_-tHBTn zP5f>mUkUf`yP1^Fu2j8jJw=~bw_CdsvLpcW2_6hgqAZ?Umf@|Jm>%wN$gM}m4^m=- z!k@b{Q@}_3j^;fxP@gqfDRivX@BTPS_-IzuUfxTxgM_s=Wir|PD=LkUaQ_?P(<`5z z_ISSg-*nxrrOV-3NgwLO_ kNEywRm3cCHOa 5X9!kotz>kHi~3xxhM1h94tFi4_GX0gR|#74!~|uoW*D<_qD6Qi zfumqsShhMhsN0Ej(5)h07m;*WsY5GwIA8+TlMC +{G{`(@Nsx}A`JJ99$R=khxm9D?~Tu*}Hx9Jw?MalQ-wId!AZtMz- zfD!BWw%erXE1f;bMEczm>*=*M?C1O${=6gdTQbQqQwCQH%SD&WN=lD4bJzF jqkpMUCY@SiHj9PtM2kBf|^gY$LG$yP+;3ii`z~Zz{$OJL@RKvg<&EpZn z;tN>yF4ublYRsXv(vv44UlmMQ$+@8y?xx81)$xnWnDSI`-c{evy!wtrQpXgjTn9-P zCQ*)GWl4C1H<&(pb6M(h=rm34=EX`^IrnDz&`W4|_B^$0;mE2p`&Sujo1WOMcQ(_9 zfhiZZiG_;Iu@j~m29?3j3t^kaET-N3@X626J{e8JnWV5P01){WY#HV7AOvxrJ4Ktm zpaa_RcMapSADN?=8F_2vzN*8GXsysV1IPu2L|d3N>)dn6U_f76cP9DlT={;+K+voo z*J$aNF$JgMotuk9>1QvFPXp=MkC782*ygO&)R-E*-o|BcHks-;G8?ZBs=R(5Q{U?L zSw`8Rw*RXn=j1OBF6-l8RMbMmAJCIOKgsZYW@Ec>6O1&Pzl>L2s)VDhfDB1RNuT~{ zJ8<=q+=QNQ4T)sHVwU-#)%w{x9lDHH0`xUVo?gLYM8@R9xX_4*{_3Afhb|Wc{M1+u zatdR@4nvR#{f47>Uf!1JhzE1L6CBzEW&Wmz$Pv|fDgE!>xpVcfH06{QZ$AHU+q_*p zAa^p*(?hAa2i2T?-^hx29GQ-M(4U0zEgr9nG~|cm3fcWv(Q|~@6P@sshQiW@?)QWZ zQ<{*IGwXL>M7KL|O2ZcpG%kh2jx{u<=&dlWp{Xc913us%i43I(rvMf>0dRpjwBrJ= z2A$}~hEuT0MXAt{=;2P4!YYgaH@I;1;6X^)Q1FTw+&XpWR1wf;%~ZX-IXLkaOrKZ; z8hIx d0Igho0klzvlW*82d!Gh78u#GEBg z)dLJ4JJ6qv7T}IZ#HZ$z*M;NKT4bG^OKCF*ej9PnZu{ZjYPu%3tob~hTYyx7MLFuL zfBLn-?PL4rq#kMG=013we6PQr`HMpA%wL-15PJGI<5MqfzW)?3`7%j$aN>78ferpe z!x)i9{vu|dDyDgZ#CHln9Ud}cxM 9qGR%{c5aeQAO=5Ul zW rH+Hm*}2OR<4@~Way~fV NH3xa;WhBDkhz)R+uJY|>As7Sy8-i> naPM`wt_NI8FB3zpLON@Z3f9X>QDbM#*oY`_-E~x|=>Rrof z9W#?QaV@>CmtJkiO{5rzce>obLMXvErMhC<-hvKf+09Z|Af5|=G88v1Um~vb+Q+Lp z47o@6d5p9P(Tij`R$u}hIjsl2ts5+O!heLu!^lOC1w;=5$ss@Lr} T#ygd{rw zM;5qZiKuZo2(8o%2nIm>h#`x7R}z81p+#eO7a+xnn48)B9fBlAY>>f@VFz9^gI2H{ zCh)AJRT1>6u^_X}`r_B(F@W>goj23MeSYdk=pq)idS=t&)N$quEIr6mletDGF9rSG z8pYW>q6E+wiwQ 8%D9Y>)>o zEh|cNurpm3P`glhYCyrYf1}8YJyfg=s4^^xd4I7&0%S&N*5xP2e6l`J7bE3iO<*SD ztMgb>k!tdMK`tY2I%8CLLw?zWpU%Sjfe;SJKmGiH=Gf}j^ZlV-?J_E_OFX&M5Z`Qs z>oB?f#~EUdWuX!JjyTsa67dva$d%GrwcxOG8a1# S>K1ZKut$5tExNWk=;lNE}$#)A}jQ=Q-Q&9UET rtj<|ixjWr+|2ORd9?lJAY&CIsnQYNR(4G8(kpIm>|G8ua-1iIiI z%77Jnp+(^A2h-m-O(}TDGa@%@9~h_#f56bBf{w(1gb_GF_M8U4JC7?Opctt#d_h@c zy1YR32dy)Sb6h`hi#KPV6@%Nk*~^4=*Nf%!OQp~B$Xa|IH{2bA`s s(^|xI3?m(zHyvto@|Tt zZrWurVF`fDybn)Vs+!MWR>VFul~=H|^4~*Y@o2@2NoILu^_zf~FK6QGpxaY-ua?Lk zThuf7c+eIP+>8X)0@DPO(1}~QVv%gzJbeL&z7oRXVmWsD21V%;6LEK%GMNH`W#-p0 zk$O@o=$ic;&tD9g!W2u(9Fuf8w0VvnC16_^Ut~OK^{E#ThsOeyIskd3d=5$nZMb@9 zrEDN1M!y1*f=N$~DZeX!PMM$4X#v3g`MaLGV=ge C4-huuHh=%tS5EfH(?=R= zD(W&CO1Cd>ZEyW=?Z5+*042qz3QzSkwTM%xhInfMS*|#5(K6OnBnQ>MPKwhJ4Pj^B z06$B_jNhq$Db*C3Lt&nDTplfe7~J73VylCl1o;xb#H&sJIikPUO8S=UCg`*0gNfjB zc^~|0li^EB`}EDv)&qWrt8q{%DYG}I4I-kuKGtz^2cLW-5BVfr0}9t}eJui$o4fgo z6>hpt%9}?%StS#-gfj*rq$PG1QZY}h%n=r4jzAotl!hY-nOFV5p77|%*X(AQtm%|5 zbTRiokc{kj4?KF(BcHH@1?gbE%FAvQ-`ZFnGUAnA=hs*noY#M)qB|MRlcQTg8=OUM z?1M>~o+AB7V$uphO6Uspt-wf@#V;R?FGBk!l;(lQIeNsTaI-S`E+OJ~NJzo2`lDY- z^K&WqX8l5RV)gah&6mG~(t?LafcZdr{>f1`g~hK+#UbHFl|qfz3(B$3;*=_O!S}qS zxRuneS? wOhMaV5`?||}7g@A%jGHdO&7l=ZKX?jI!zbV5K zCw0=Pwjq_m;2YJ+fci^U@~srzjO1r+-zSWw+kaCnP8+!5kTiyrGUrz6adU)3r{y^b zI_Pu4 a?~zxeb~9Sb&Eajaf>In+fYmr=NFprR0) z<7@x&G;qd6vi&hiV&|#vjk`5QZx^Pf`Yf`Wa66Z=gX3$(Bp#=IN%+qQQ^3C;E#^j` zzig6^?MHjBy34zTQ0{|-%Z~aBaRHnFa@U;5xi3Qb<{R~%wrj%LQIJ}@4 PPRj6B4z!t2lp$qNNFT~G_cMINE-y+VPM$TQ;+gQwpLNtz6Ux;qadKZ`<$Hg6I z^r(ZjtdSDdIQtDZTAp_=b S*x`;E_~Kjvp`AP=(!f071SOdA?W`aZK>MD4P3qSE!anOPY!qk-u%zuNhJzB-%a zP+~nK041++9G|;_J1X`6I(e#9p_i++|L7BsT0qEHxfW)1Y%u{6?J{1t&CkoOXHij+ zhE>$hlRiiTNTpV@F)!4#@ye4T7zoimQ`?N518)$W_=T}ck>y|G=IB4(J5y(hP@mkE zdQfhyHlgpJ7h<}Dk-cm-# D_5zV z%HlIGb*=bZzD5^+MELW6grQ1N>QZTmKHf5thTM a(9SU@JN_~b1m{?YGSi~1}h*RZTZZeJ8T>LZg?tpbIg-aHYe z*(jSmP{b-xp5t1Uh5GG4kwTrzBo@!gtLW_(0RJ9x$2by-sI^RPM6h^*7ysMFy~Pds zL3k`t1VL&{7A&PZ%RyWFDV)z;s};d&CH8C$ivOq>4W-U3%-lT4MIL#lC)rW3xsEq1 z{~qNDOp!J(x?{u{k#aFkZ!di5wNh9hHicGR?DItSwVIg1Q%eRk_KK$>Oxg0<>iAb2 zZbT?o6(KCa_hJ%Jn<9?=D3%dOmEQa}1Q!t45-zxcI*U$u-UympSYg{M@z$8eX{W|< zfJZkQ Lz+G({Bboi-HM`Rj*|p0{5luXz%iLUgaU)ohyL+_3kR`5B6ym)Sl-sYm5eEjiU_ z tXN0~sbO1u~n>9cF~(Z(-%Z1p+y#ro}|4 zVuY`g_ P+tclJHVT<` It} z#vX!*u$#>e@n^?&15-hU547hyqcv#@zbP{^)>`6W4D8*K#*RMHJs5^BS*Jj|n)Pr= z`#MGVADQvIYh~eynnz78WGwILSdb0uYF2{(aE#%ox*AwNw#7i$W>8_#*GPr=s=M<+ zzaf=>{q{IS%b0B^HrCpL9>`Wjt}Wn+um&}8Y zeS?IHBtf@Vd-0>zaWgj+(HGGBKyNKe6$m-5J2 KOtM#_r8C`B~zgtd>U>5d0!EzO6zG5h4Pqu)(YcIK6g)j5l8umWlKnoSxr<*Dn z $%SUP?8u=YoVhw96%-!<#=) zyrVKRFg;t;!##}C=@SQpYP(?QYy=;lEcVgqmC-jc<8H&sxs>Ac!NEjU4_tg+95i+l z%Xqi>ZoWjUN&DA|4X1jNHp4Tb>3PIfYBmJb$Rolku}e9a>w%=u6W(v}Q8ow&fnesK z3C !~IDkgy1XW>G}`zaS-SD|y-zk?n 7O*RuCNZAEb$w3TsP(+uPS!8vCL~( zhw^c9KGaO1zUeafhOwNKM*S>PB 7Sp{# `k_P<^MJ? zpv#W~dXI2%qOLjan~hv`xYcixKvliiCw-REq)Qjjh2w4-jigao6j~8RFK+XGV!7p! zSu09&`;fOE#l NQZ|wo9$n< znNmxyVK7ycPMQe wi(R_S2Yh%aNGG+(U`Z1R4V~Oz{>-N2q5-eIOf-c&{}>K zNweNW{^!}}ZyM@eySb)1)2Y$@5(SoI#C-^0Sn`3pwRJZ-e0Tv V+*W$C7x2sVMC_#8m>E^e&%upPH+4Yk{C!k%&ClJ`x l)Cs(o|vgZMupDC*lSmCF#uy=6rv;zdx(Uj zz6v3V(8mBC=}$hZ`W(wSs8e Y-mChUH(1nU z>8wDQMA2c+KVO;*mZ}(kzVtDaP>+9K@0w}4R(#;SE?CpOF{d=r#CYE)wK?_YRNDLF zM{i7ih=mBGb6kEd-1gsZO44wT%b!T*x&r3YHGsBjGSA$va3`hWnTqp8iql>82*Co! zQNxiNEt0fOgL)mG){De#a*WZT-E~pY@JaLXIGC??Cm|XgV|Ei-eu1bwqaM}pIkcgn z3rI+s9c$e2UH7Lb_8U1dSkN#kA?#iI(L(Y58}0>l<@?TZjre7L>_a14BkK%ULv0AO z%W?Znw`oI)xug1|4Cmx?jXbSoSVCe9(vsROLc`-i;<&5C@N{IcWnUvi`03Miug%RR zs@>9ud@Ff-ukF4R9dEq(JORw}^(mkRxoors9(}~+Xx=h%q;0r1xr8cW<)bZ`GCPk7 zo31erJy^$fQX=PUF vnF6!MXgt&5!$7;6vy4+jbV> zwahCMf;uKg@DC%#(1TFH=dYv~Ybo60eU}ZVXAlLh&uE6bhzB3vS5>{Lh4>6MYL)HQ zu>{uXPuk$)_$dr%7cVDnMF%-N8q?gy$?C^(u+q~m&Od5O)g)q*hPdFXTd4+Aekd ziY;W~DAO-nZoghA@t#&DVg~%YZ9N{}_{I1^<2a+3B}*ouJ2izPo>#9vC+HzjPnG(? zGk%XvXEY2Bc{9_Lm|}dssfMxnu6f{}R~p%%eF28MQwL=5f3BqfRPx*R;GfRCz74ny zzKy$$l2cI8F;=-Pxy`>#yb$g#vj~&M OCSn?HGYH|Q9#eyOJ%TJAmBa%Y? z+rA?Wr zElsH|+rfs+Si4D-5XZ4@k zK8^IH%P1_&+ZHE;gueSxiC@G%4e1$az( z&=cYZ0M!73foKWs2NN~`1%}j>CG2(}clS35_#M#L2umzlOhgYECUD3?p%Lb&L5qq; z%C_1Ev_Oz0?8^?xcsWx82uk%!BnYqZNdMmF&;alQN&Cm4qz;Z}NVT69P+$+gWXvL- zc9d>nJuJ(yDNj)#b~Y+NLI=I0#vfw9{=NjT`U_<=N;0E=I%)j(kBmUv{LSHX?FtK? z>{7NGTs9K7{7dqFPas0fk_v*M`L>gxRfi9+ub@-5HAkA**dDJI&}=?N2fo3bkCRgr zAL@=Bma3l5hQwoF@RqebdNU^a*jDkdrsU~fl5^EWnx)=@sS}xnEW1qd-c+R(Gt~$T znEdFQ$ %E0s-*^ zx&d0+&4+jh6Nrfo_A5+n%m5=+sdm0>dSV1L)6U@-{{jh_utk5v?QJq9#>YR}@#o%P z-kIR2!yPEKXh-R@z>>6@lxE1^Q3q}H;RK3dRN%Ky86*o>8bF~H&o{X|m{$k#jd| z#UA}?*fg%xr~q^ Ve7w0$bDvZ3fbsdI}wxF(djm!sw=U|o#5$C>!xnM#wn z?`3UVD67-66F;L&>^OG)DI=Q_ry@_Qk5+G<8$)@-UtCY P4E2j&`i-yU;$9Wvl8rbkFY$ zVlTgvF42bn52D^WD$1~H<9%iZhVJg}lx~Jnx?4)5yOAD{P)bValujk2OG3Is8tE3K z`^@{k-#LfHU$9t _rC7y`fYi529gCplvCnoXgY<;<%SgbMHaM<0buqcdlAyz zY2z++qXMfAE5ni=TwM07_}?5PN4W~3yJC?>|LX*lO55pE>RsVI-}se^njpRG+2cps z{YXZ7>h!x&1?*W_prtp=NZ^6siXad>&2-WLuM7Bt$w9P*XR_(oc4e1y0Jc$wCJ_4L z1$iqjE7Pk;bt@WFHC9ow#v=)83Ekx3qq9wNXSWACwuI5%W?$vRq)C$%jd!pPr|GVb z8LIrhEd%zb&2c4TGUI;a6p7O~ZJ-~1ap#XeylblSLfQxp(rtn7JalWcBB8qG=KN#8 z7ja2yaK&M^Ww573FE; L(U950V8;F?qJvCexA{r&xYi{@kt>I`*Id) 4p}oX8TnLpm*|@#w zb+~Xe5_<@uP{V0}E@{(|-$DCx_gh#4G@ t )R$K 0!WWjxs4O5&y6y*D%wnZ)0IeD-h!YDt_Yosh&|`Gw?#PHhjV z(Ad&yj6LlbZ_|&%+$$LxZirJt(VIHbl~Rm=s9H1yZWSh=Z)Ad3a2S0h#f2)n%W>|6 z7@Yb_f6~n@wPRY?=yms0K&?~H^i!`KlLGq{Hp$<{R!K>(iBwNLy^{N6FFj-!lUic& zFK6!j)oS|@S&p)!;wXtgmL3ZEjFz0W*>`8WFPpZJR@UkO4BXsb4ixstH~?msBhn`( zrZk|e3j*f_mVp9V5Fjz3%MZo}13&DuFB^2Q=s^P}+7wN{6bi^_cmDfOko8DEAG9y1 z)-|q%h+BLYG$0{~m!6ZaXBy5I#F}Jh;$kaS1YpK4pIXiBBXP`QfW1p`+B>-CsHaVg zR&cN_)Z^0PU#5?K@u6($C!v!1%hh{wYsuNWzd7SrlFeBrd%2iXT3?`Z38>bbkXIr9 zG9gF?LXSe@G4>ZAm6kw<;;U4Qzk}&Nbb2G5OkK5}|BfW+GC#kHI>939Y;g5RXLZBt z>`&?3D8b5x{+QPJ1;B5Ij_`f=@=IO(!$RM+2=f9(51DU-klD=OA*1qK+1#BSbVzIK zpJ~=fbdr0$Q(b7?Vsx$bPRRGmLT>7|M&AA1G;g(jDYr@eq3KI&ZuQ+}Lv`CT^4pM} zcz`)5iL>@EC+>&&K2^HF1s;CSPF-zH26?ANQW2lN&c3E&aXxyVA0KbkGKG{<1AR}u zM#_9L3NfhhOP9Zn0<_i&Y7y`>Si2h*U?~Wf4PaDHqjcS@MCulD@?d%j`>t9F-V3Cy zG~T7qocdI$M^ZI;3l?j#5R=xsf9zXWwU2rMlqk&qYQOB!UO~B~V;9MH+xfHGL|5%w zyH%8&Y2%V%(Xmgtiv>sK{2mGtVDBy~ fBqD5_ja W;kUh8rMDWd zOx0uTy(L56tCU2mK~;+coQ$ofaa*37qNImLKvaoqC6&@$PA*6tVsB;l2O?v*EVF$! zc%R2AinccItf)RQD453|wEjKX3m{jBNfN=7`>LSqM%sVgNYU}G;dtUwBB61ofszT7 zVk~1_=!Ny@E1vLOgD8~FNyr J~kM5 z5ixD@8J(Z%?PF|Mn!nb?rh<7m*C0{k_c;c7 EcB{lt7 z7)pDW$KnR#WDTc!9jb-NLn0<7*sq{wnV*Q}DP1Pp3kwS;I-x DDl4)(eVm*2Dp~fIf8e}RltDOF~L|zw$zh_DcwYz)V+Z1-E?iM zM 695}JyZ|c_I+-^ZZV3Vak8|`_fF@GH7lU+zfq(15NrsGlmNZ?v1PSDQblr*H z5x1s<6aerZ`hjC&%7dOi1~JyC!`87@jZK#cg;7%BU(f@MnN~{{nB$=TYnnkotb9+O zSLzswYu@d yn^kk6h@>~1fE58!6?itY9Q@F)=GH?utE z)4JP7{48L7a7FU# zymkrms{OsXy25tJC!YBpJ6D@})jCn @3 zu8V%l!jVWs<|a_ELrM+<{BgQbgX;SfKA )hkR3 ;W=oK#=bBw| zH1a56bTL wvw3YNL$xY`(cU@ iwk}QsBA}Dbu$yjy8)&uPq&6tmD3V|m_-{}*qGEma}BL4 z7~tGUGn`C$Dw=YXx>nQkTi0voJg9YG(C9J5uRp%JJ%r43u_la1hv&_h61{NlQ|)84 zG4<+VxaJ4h`TF}q@%U-aW_+~oFV-KuoFG^$iTQJ`?{`cmW;f#fL8P?lh8)v8SW1q; zi^&nG{S1uo6PAjWLNvQ~Q|2u-jKJWB7$ktc* U;73?q7gU!9b$bsI}cjzh$&JaY6?I9ipwm zdb*68)n4X6*;|8=^4{8ZF_XTVOo$y7L_x(Az@U{El_2vo23>N=(W=!MbTQ!q9o^tp zPz2-Xzew1wVZ@<^`1+5CgZLq^ZX3r>_8gC3pTM6pU#S~wyhi9bp3i>I;m@(p@1=~Z zcE~Kd3Dz&f>R$VsFP5`m)BSfg4Rky6=pNs8LG7}>&mcAVo!s;ev-%m;W^MOJTw%s- zLXuB98^Za?-R5l(7=V)Cbs3K&SH*CgnI3%9-Ue{=Er~HFfWk1Z+mr{X+x)3czpJ8d z;bwHac=~m}ZZC&sbZxnsF+2H!`;@pu5&L5Z#q#+-yoS)pTPg|% qBESX5lfK!CfSuZ!cmVY~n8tZ#AI@q>4Zh|(l95Aw5H>fv z!**BpB4O9};exDdQpG$O_U^VLK SlO#$XV@i0Ds#d;lPM0LA6|PS zf`Ml(KCHjkw*;njY|6s2;R9-B*Y Rm^{%b6x)IG+OiA6HrdTc#VdQxI}3+>4eK z3w93B!c3X~epw~lZvi-T2p%X#>Htj`D-_5vHXY*EeGk?2H|=Hkm3+ph;Pzh_fW+p8 z?dfU7@Ee5H5V$o4qqu^O#!i(7Jz_@!bTGWjESMQl0houFTPF=5T6OE6Tt&Y yfd+ F3XXSp?ucSUHPZ|imXB~ zo $S>z+%B`T&_-$6sPuvD1%rHBRKESLvt6-%ggBUiC7>JcWfg zI+_^__9mj`J#eSe;(cbh+sxL0LTNpq gf7tWuq_Y3DWSIdfF~{2^EO)%(h>D8C;TW$7p(38A5&>oAF#-OSiu&9E0~X zkKK+i!h=`6rH^x;15NnGKtLc^b{P!7;S_oIip*}wP*7eoSa$~WO`Qk658;%+Mgq)~ zO)mxMiPbO&oz!bsT5Yuj&=s?v|MOJQ)SnRCb@rvt!uw-V&G)fvO?KaO&cLKzQ7ENF zN2b&dt0E@XxpgeB%~oK2B}rE8nr<#t>Q{oXy2|#x_3Kn@GS;oPQfiEfr@C=LEEmeO z@7E7GTlbIMFSUAxU^V;mt}Q9Ia%>o>ay>~zEZKgoULS~Sd4`iiP_&)-Vum_51{w2I z%qp#Kud=cvPXATs{JoY5ea}xLo&B7`oX=v*!LcR-8}vBH{rwd@(IxfzAesvd{Sez& zuwN5Nb`z+P%gYZLLFUmxDUN>7w`VDZ6_7yP_)R4o10NN6vAjo FjrX$W}=)B?yV2V9TTN dT}Jlum8GVp*3%fpDEh5gJh zj5ABra_a_A0iWI%!N3(;cAsDo@=K7DG0PPyeK&9e0!B{!*Iqxi>OXC-J-_Mec|G~K z5AwKv+8&m4`&0g{1TryMxhUkMSjJt%i3(WE=qZdno}PWm>I_?uUOZ (TG0HEU>$ =a1Ev#+ng>g)ZbeL Dj#1R5P6J0Mkt%aggH+k5dfDgH**f+rfuZz$VKNqmV zdPqn7k>ZhnEX!#OENjEK*kAq)b0~Z3k&e=U{+ASBjOrA=xRGtDt68?=x_vF5G;pyb zIvvP2{)gOyMc6fm#9)Im@6mG;Do>P#ZVNKeUm5f%n`s?8{w|BCKd9*j`q&6SgIhmv z!&VovK3|e^`1^L?m>|53$nj0tpwr{QFr`f<|5?&|*@2F&!w?Q)zp0bfCE7xQC|v>| zP BJ1v6LjyF+3O{pJ4K`&VA> zcfSp6Q1Hm*3!ff1apr84s7BKoz_*S&^uKC{$B^Ab84b{)5&%_l(2pXhSZtX()8`yT z;*R|X-O;};{GLu=VtLvXxi*K+zLGc^G3>H(eavCyxo@(umQ{8zVhm%9SCW5=J_EXM zhKM=;Mb~?xmg_p-`DH1)BbR^CV0=6%)5?JZ!X{_Pd*zi3r#?&Grv&3 sk%PW>p4eA-JF%OPA1;U_R-a|`F@LQgGKVk<&8$ 7*NV>yE0BkWV2b|^@=|4qw^AaNi0B~|uB#$h`uzSz&8Jx)R}WVU4y=7+?;!S$II zx#~@P_GvlVq-kD91b=%@3rvZn&h1t*pJ|LWUq;@IGzOJ8zd!#W?Vf*HIh?CMm(N31 za _snOmA`hvRZ P`QMfdX*&vYtixX`k>B8^0bR2E`HI4fM(s_`09mCT zbn(rHBkmJ!c7vw`y# EzozKVLSDLPRT490iTBHXhGw+0fXM88v^ z4kI*Xh()OSkZ#y31Y9x`88?O#z=ee()PC=M@;H>C@Rts=t&Z&2tdRT<(ulIrrRLj_ z1~Ghd?zh??^-@rPu}?q+p{t4XhGunOtVc*tj5eHiq!aA~nKu-?T;q>2t3TQf{bSJD zq*MFeZSY9u9f?u %QZh2(@vqPg%FCAb&5rfWh)o3jD>#EtQSKRsZ+_>}0YEB4PV zO$+2z(2)Lq>Eo)evm+}`y8aj;*Mb@4%h&4bFUW*1US9IxUAb`swR?<*>|yAyh#^1` zO`h2YD8itpp4Zd0j!d1t+Bjyvmw0@ 7b<$V0avA|TGYbo`e*D)^(YYQrDsLi9{k`&5?QP6-~$RXdVB6at!lI|kol zeg#Ec4Tn7SU;yx??7%6-jed-YH6DvK@dCNmj+NSMMT{6=AZK-D|AhCr?gWS0O)ALx z>+oIrXt~nhCkTnDVY}22=$UYIxyEVnNe7Id((&?)_6@UPd7kb^wGPQQs~amM@A?vX zq$o|5Km8F<7%gsx1w6z>j#AY~?ptm_k$-y!e<91#Zma4j2BBR37Ol?; WB^&<~J%YRY`^|Xnm{uM0H8v3k?X;eGke<2XT*Po#$ zISP%0f2JlSnjruErS*~1@|a=s^5 tm-oO5_hnRyZ87M~6y0O#O) zF}jpEmUa6#J dGZO9j?zzGgN`+bO`{Xed4_Mgd`4Ch&_&=S4 zDb+$2-)ahL1n*!oz{Gdnu@G4KWbX42%Vdp|$eotiTZsGgJGn*)5U{O3QJ Rk`u$+gUY5FJOw@Y3x1k;`Nu8lH( zvxM(qZ2!$0>HFi-Kds9mP(MSEuqEYUWBY>o16vDeOZ+mDa^>z**ryn8HZPo0%t0%a z92T=F?UV5>j@F>wPPR`!@Tz{DU)R=U7Uz=XU8UGM8{u~6&nL^kKhXXI>`m5D6-gtP zXc9O +~E99r$NtD#|8?(F|Z5Ivx#`k&LZ7Whs%f{_O4Pko-%Jm15(Lq7k*vq0zqf0 z-g2epTu+S*o2hdXTpfWYhf??(Yv$w?Va2a6e{DS);E4Cvsg2OhjbEFlQ^Iy4nuOpC zu3sWtJ6$CR>9}F1+PA%GVBO~tvMjyzWZ%q^ne^7bQEL$~J*V+#)UPu#3 U$l>pwPQ>2lP1}=_+Uwzj)iB5c|H-;;S4?}tA1_fvojkZRB$QaT`jS