From af67b20d9b7cba72a2db865bd35b308fad8c01a8 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 26 Jun 2019 18:36:54 -0700 Subject: [PATCH] Initial WebAssembly Build Target * Initial WebAssembly build target for Doodle in the wasm/ folder. * Add a new render.Engine implementation, lib/render/canvas that uses the HTML 5 Canvas API instead of SDL2 for the WebAssembly target. * Ported the basic DrawLine(), DrawBox() etc. functions from SDL2 to Canvas context2d API. * Fonts are handled with CSS embedded fonts named after the font filename and defined in wasm/index.html * `make wasm` builds the WASM program, and `make wasm-serve` runs a dev Go server that hosts the WASM file for development. The server also watches the dev tree for *.go files and rebuilds the WASM binary automatically on change. * This build "basically" runs the game. UI and fonts all work and mouse movements and clicks are detected. No wallpaper support yet or texture caching (which will crash the game as soon as you click and draw a pixel in your map!) --- .gitignore | 2 + Building.md | 2 + Makefile | 11 + lib/render/canvas/canvas.go | 37 +++ lib/render/canvas/draw.go | 66 +++++ lib/render/canvas/engine.go | 77 ++++++ lib/render/canvas/events.go | 102 ++++++++ lib/render/canvas/text.go | 88 +++++++ lib/render/color.go | 8 + lib/ui/main_window.go | 2 + pkg/doodle.go | 6 + pkg/main_scene.go | 4 +- pkg/uix/canvas.go | 9 +- pkg/userdir/userdir.go | 10 +- pkg/wallpaper/texture.go | 6 +- pkg/wallpaper/wallpaper.go | 14 ++ wasm/Makefile | 5 + wasm/README.md | 51 ++++ wasm/index.html | 54 +++++ wasm/main_wasm.go | 84 +++++++ wasm/server.go | 128 ++++++++++ wasm/wasm_exec.js | 465 ++++++++++++++++++++++++++++++++++++ 22 files changed, 1222 insertions(+), 9 deletions(-) create mode 100644 lib/render/canvas/canvas.go create mode 100644 lib/render/canvas/draw.go create mode 100644 lib/render/canvas/engine.go create mode 100644 lib/render/canvas/events.go create mode 100644 lib/render/canvas/text.go create mode 100644 wasm/Makefile create mode 100644 wasm/README.md create mode 100644 wasm/index.html create mode 100644 wasm/main_wasm.go create mode 100644 wasm/server.go create mode 100644 wasm/wasm_exec.js diff --git a/.gitignore b/.gitignore index 7734e99..3d1e81f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ fonts/ maps/ bin/ dist/ +wasm/assets/ +*.wasm *.doodad docker/ubuntu docker/debian diff --git a/Building.md b/Building.md index 84c2109..d5b894e 100644 --- a/Building.md +++ b/Building.md @@ -27,6 +27,8 @@ Makefile commands for Linux: Build Tags below. * `make build-debug`: build a debug binary (not release-mode) to the `bin/` folder. See Build Tags below. +* `make wasm`: build the WebAssembly output +* `make wasm-serve`: build the WASM output and then run the server. * `make run`: run a local dev build of Doodle in debug mode * `make guitest`: run a local dev build in the GUITest scene * `make test`: run the test suite diff --git a/Makefile b/Makefile index 5031a4e..25a7ee3 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,17 @@ build-debug: go build $(LDFLAGS) -tags="developer" -i -o bin/doodle cmd/doodle/main.go go build $(LDFLAGS) -tags="developer" -i -o bin/doodad cmd/doodad/main.go +# `make wasm` builds the WebAssembly port. +.PHONY: wasm +wasm: + cd wasm && make + +# `make wasm-serve` builds and launches the WebAssembly server. +.PHONY: wasm-serve +wasm-serve: wasm + sh -c 'sleep 1; xdg-open http://localhost:8080/' & + cd wasm && go run server.go + # `make install` to install the Go binaries to your GOPATH. .PHONY: install install: diff --git a/lib/render/canvas/canvas.go b/lib/render/canvas/canvas.go new file mode 100644 index 0000000..c5eb82f --- /dev/null +++ b/lib/render/canvas/canvas.go @@ -0,0 +1,37 @@ +package canvas + +import ( + "syscall/js" +) + +// Canvas represents an HTML5 Canvas object. +type Canvas struct { + Value js.Value + ctx2d js.Value +} + +// GetCanvas gets an HTML5 Canvas object from the DOM. +func GetCanvas(id string) Canvas { + canvasEl := js.Global().Get("document").Call("getElementById", id) + canvas2d := canvasEl.Call("getContext", "2d") + + c := Canvas{ + Value: canvasEl, + ctx2d: canvas2d, + } + + canvasEl.Set("width", c.ClientW()) + canvasEl.Set("height", c.ClientH()) + + return c +} + +// ClientW returns the client width. +func (c Canvas) ClientW() int { + return c.Value.Get("clientWidth").Int() +} + +// ClientH returns the client height. +func (c Canvas) ClientH() int { + return c.Value.Get("clientHeight").Int() +} diff --git a/lib/render/canvas/draw.go b/lib/render/canvas/draw.go new file mode 100644 index 0000000..fd8d8d2 --- /dev/null +++ b/lib/render/canvas/draw.go @@ -0,0 +1,66 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// Methods here implement the drawing functions of the render.Engine + +// Clear the canvas to a certain color. +func (e *Engine) Clear(color render.Color) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", 0, 0, e.width, e.height) +} + +// SetTitle sets the window title. +func (e *Engine) SetTitle(title string) { + js.Global().Get("document").Set("title", title) +} + +// DrawPoint draws a pixel. +func (e *Engine) DrawPoint(color render.Color, point render.Point) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", + int(point.X), + int(point.Y), + 1, + 1, + ) +} + +// DrawLine draws a line between two points. +func (e *Engine) DrawLine(color render.Color, a, b render.Point) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + for pt := range render.IterLine2(a, b) { + e.canvas.ctx2d.Call("fillRect", + int(pt.X), + int(pt.Y), + 1, + 1, + ) + } +} + +// DrawRect draws a rectangle. +func (e *Engine) DrawRect(color render.Color, rect render.Rect) { + e.canvas.ctx2d.Set("strokeStyle", color.ToHex()) + e.canvas.ctx2d.Call("strokeRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} + +// DrawBox draws a filled rectangle. +func (e *Engine) DrawBox(color render.Color, rect render.Rect) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} diff --git a/lib/render/canvas/engine.go b/lib/render/canvas/engine.go new file mode 100644 index 0000000..f3edd19 --- /dev/null +++ b/lib/render/canvas/engine.go @@ -0,0 +1,77 @@ +package canvas + +import ( + "time" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/lib/render" +) + +// Engine implements a rendering engine targeting an HTML canvas for +// WebAssembly targets. +type Engine struct { + canvas Canvas + startTime time.Time + width int + height int + ticks uint32 + + // Private fields. + events *events.State + running bool +} + +// New creates the Canvas Engine. +func New(canvasID string) (*Engine, error) { + canvas := GetCanvas(canvasID) + + engine := &Engine{ + canvas: canvas, + startTime: time.Now(), + events: events.New(), + width: canvas.ClientW(), + height: canvas.ClientH(), + } + + return engine, nil +} + +// WindowSize returns the size of the canvas window. +func (e *Engine) WindowSize() (w, h int) { + return e.canvas.ClientW(), e.canvas.ClientH() +} + +// GetTicks returns the current tick count. +func (e *Engine) GetTicks() uint32 { + return e.ticks +} + +// TO BE IMPLEMENTED... + +func (e *Engine) Setup() error { + return nil +} + +func (e *Engine) Present() error { + return nil +} + +func (e *Engine) NewBitmap(filename string) (render.Texturer, error) { + return nil, nil +} + +func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) { + +} + +// Delay for a moment. +func (e *Engine) Delay(delay uint32) { + time.Sleep(time.Duration(delay) * time.Millisecond) +} + +// Teardown tasks. +func (e *Engine) Teardown() {} + +func (e *Engine) Loop() error { + return nil +} diff --git a/lib/render/canvas/events.go b/lib/render/canvas/events.go new file mode 100644 index 0000000..6c3b74c --- /dev/null +++ b/lib/render/canvas/events.go @@ -0,0 +1,102 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/pkg/log" +) + +// AddEventListeners sets up bindings to collect events from the browser. +func (e *Engine) AddEventListeners() { + s := e.events + + // Mouse movement. + e.canvas.Value.Call( + "addEventListener", + "mousemove", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + var ( + x = args[0].Get("pageX").Int() + y = args[0].Get("pageY").Int() + ) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(y)) + return nil + }), + ) + + // Mouse clicks. + for _, ev := range []string{"mouseup", "mousedown"} { + ev := ev + e.canvas.Value.Call( + "addEventListener", + ev, + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + var ( + x = args[0].Get("pageX").Int() + y = args[0].Get("pageY").Int() + which = args[0].Get("which").Int() + ) + + log.Info("Clicked at %d,%d", x, y) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(y)) + + // Is a mouse button pressed down? + checkDown := func(number int) bool { + if which == number { + return ev == "mousedown" + } + return false + } + + s.Button1.Push(checkDown(1)) + s.Button2.Push(checkDown(3)) + return false + }), + ) + } + + // Supress context menu. + e.canvas.Value.Call( + "addEventListener", + "contextmenu", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + args[0].Call("preventDefault") + return false + }), + ) + + // Keyboard keys + // js.Global().Get("document").Call( + // "addEventListener", + // "keydown", + // js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // log.Info("key: %+v", args) + // var ( + // event = args[0] + // charCode = event.Get("charCode") + // key = event.Get("key").String() + // ) + // + // switch key { + // case "Enter": + // s.EnterKey.Push(true) + // // default: + // // s.KeyName.Push(key) + // } + // + // log.Info("keypress: code=%s key=%s", charCode, key) + // + // return nil + // }), + // ) +} + +// Poll for events. +func (e *Engine) Poll() (*events.State, error) { + return e.events, nil +} diff --git a/lib/render/canvas/text.go b/lib/render/canvas/text.go new file mode 100644 index 0000000..a4ce4aa --- /dev/null +++ b/lib/render/canvas/text.go @@ -0,0 +1,88 @@ +package canvas + +// Text rendering functions using the HTML 5 canvas. + +import ( + "fmt" + "path/filepath" + "strings" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// FontFilenameToName converts a FontFilename to its CSS font name. +// +// The CSS font name is set to the base of the filename, without the .ttf +// file extension. For example, "fonts/DejaVuSans.ttf" uses the CSS font +// family name "DejaVuSans" and that's what this function returns. +// +// Fonts must be defined in the index.html style sheet when serving the +// wasm build of Doodle. +// +// If filename is "", returns "serif" as a sensible default. +func FontFilenameToName(filename string) string { + if filename == "" { + return "DejaVuSans,serif" + } + return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) +} + +// DrawText draws text on the canvas. +func (e *Engine) DrawText(text render.Text, point render.Point) error { + font := FontFilenameToName(text.FontFilename) + e.canvas.ctx2d.Set("font", + fmt.Sprintf("%dpx %s,serif", text.Size, font), + ) + + e.canvas.ctx2d.Set("textBaseline", "top") + + write := func(dx, dy int, color render.Color) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillText", + text.Text, + int(point.X)+dx, + int(point.Y)+dy, + ) + } + + // Does the text have a stroke around it? + if text.Stroke != render.Invisible { + e.canvas.ctx2d.Set("fillStyle", text.Stroke.ToHex()) + write(-1, -1, text.Stroke) + write(-1, 0, text.Stroke) + write(-1, 1, text.Stroke) + write(1, -1, text.Stroke) + write(1, 0, text.Stroke) + write(1, 1, text.Stroke) + write(0, -1, text.Stroke) + write(0, 1, text.Stroke) + } + + // Does it have a drop shadow? + if text.Shadow != render.Invisible { + write(1, 1, text.Shadow) + } + + // Draw the text itself. + write(0, 0, text.Color) + + return nil +} + +// ComputeTextRect computes and returns a Rect for how large the text would +// appear if rendered. +func (e *Engine) ComputeTextRect(text render.Text) (render.Rect, error) { + font := FontFilenameToName(text.FontFilename) + e.canvas.ctx2d.Set("font", + fmt.Sprintf("%dpx %s,serif", text.Size, font), + ) + + measure := e.canvas.ctx2d.Call("measureText", text.Text) + rect := render.Rect{ + // TODO: the only TextMetrics widely supported in browsers is + // the width. For height, use the text size for now. + W: int32(measure.Get("width").Int()), + H: int32(text.Size), + } + return rect, nil +} diff --git a/lib/render/color.go b/lib/render/color.go index 699abb9..3ec3ac5 100644 --- a/lib/render/color.go +++ b/lib/render/color.go @@ -113,6 +113,14 @@ func (c Color) String() string { ) } +// ToHex converts a render.Color to standard #RRGGBB hexadecimal format. +func (c Color) ToHex() string { + return fmt.Sprintf( + "#%02x%02x%02x", + c.Red, c.Green, c.Blue, + ) +} + // ToColor converts a render.Color into a Go standard color.Color func (c Color) ToColor() color.RGBA { return color.RGBA{ diff --git a/lib/ui/main_window.go b/lib/ui/main_window.go index e12c154..b62450f 100644 --- a/lib/ui/main_window.go +++ b/lib/ui/main_window.go @@ -1,3 +1,5 @@ +// +build !js + package ui import ( diff --git a/pkg/doodle.go b/pkg/doodle.go index 5250fee..4f38117 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -64,6 +64,12 @@ func New(debug bool, engine render.Engine) *Doodle { return d } +// SetWindowSize sets the size of the Doodle window. +func (d *Doodle) SetWindowSize(width, height int) { + d.width = width + d.height = height +} + // Title returns the game's preferred window title. func (d *Doodle) Title() string { return fmt.Sprintf("%s v%s", branding.AppName, branding.Version) diff --git a/pkg/main_scene.go b/pkg/main_scene.go index 3c7fc30..2eb83ff 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -106,7 +106,7 @@ func (s *MainScene) Draw(d *Doodle) error { label := ui.NewLabel(ui.Label{ Text: branding.AppName, Font: render.Text{ - Size: 26, + Size: 46, Color: render.Pink, Stroke: render.SkyBlue, Shadow: render.Black, @@ -122,7 +122,7 @@ func (s *MainScene) Draw(d *Doodle) error { s.frame.Compute(d.Engine) s.frame.MoveTo(render.Point{ X: (int32(d.width) / 2) - (s.frame.Size().W / 2), - Y: 200, + Y: 260, }) s.frame.Present(d.Engine, s.frame.Point()) diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 7d22faf..ccc5191 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -3,6 +3,7 @@ package uix import ( "fmt" "os" + "runtime" "strings" "git.kirsle.net/apps/doodle/lib/events" @@ -127,9 +128,11 @@ func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) { // TODO: wallpaper paths filename := "assets/wallpapers/" + level.Wallpaper - if _, err := os.Stat(filename); os.IsNotExist(err) { - log.Error("LoadLevel: %s", err) - filename = "assets/wallpapers/notebook.png" // XXX TODO + if runtime.GOOS != "js" { + if _, err := os.Stat(filename); os.IsNotExist(err) { + log.Error("LoadLevel: %s", err) + filename = "assets/wallpapers/notebook.png" // XXX TODO + } } wp, err := wallpaper.FromFile(e, filename) diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index bdf817b..12ad41e 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -4,6 +4,7 @@ import ( "io/ioutil" "os" "path/filepath" + "runtime" "strings" "github.com/kirsle/configdir" @@ -38,9 +39,12 @@ func init() { FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts") // Ensure all the directories exist. - configdir.MakePath(LevelDirectory) - configdir.MakePath(DoodadDirectory) - configdir.MakePath(FontDirectory) + // WASM: do not make paths in wasm. + if runtime.GOOS != "js" { + configdir.MakePath(LevelDirectory) + configdir.MakePath(DoodadDirectory) + configdir.MakePath(FontDirectory) + } } // LevelPath will turn a "simple" filename into an absolute path in the user's diff --git a/pkg/wallpaper/texture.go b/pkg/wallpaper/texture.go index 7f27030..ebdc87f 100644 --- a/pkg/wallpaper/texture.go +++ b/pkg/wallpaper/texture.go @@ -3,6 +3,7 @@ package wallpaper // The methods that deal in cached Textures for Doodle. import ( + "errors" "fmt" "image" "os" @@ -14,7 +15,10 @@ import ( // CornerTexture returns the Texture. func (wp *Wallpaper) CornerTexture(e render.Engine) (render.Texturer, error) { - fmt.Println("CornerTex") + if !wp.ready { + return nil, errors.New("wallpaper not ready") + } + if wp.tex.corner == nil { tex, err := texture(e, wp.corner, wp.Name+"c") wp.tex.corner = tex diff --git a/pkg/wallpaper/wallpaper.go b/pkg/wallpaper/wallpaper.go index 98482a8..5dacad7 100644 --- a/pkg/wallpaper/wallpaper.go +++ b/pkg/wallpaper/wallpaper.go @@ -5,6 +5,7 @@ import ( "image/draw" "os" "path/filepath" + "runtime" "strings" "git.kirsle.net/apps/doodle/lib/render" @@ -16,6 +17,10 @@ type Wallpaper struct { Format string // image file format Image *image.RGBA + // Ready status is set to true if the wallpaper loaded itself properly. + // Notably in WASM, wallpapers don't load currently. + ready bool + // Parsed values. quarterWidth int quarterHeight int @@ -51,6 +56,14 @@ func FromImage(e render.Engine, img *image.RGBA, name string) (*Wallpaper, error // If the renger.Engine is nil it will compute images but not pre-cache any // textures yet. func FromFile(e render.Engine, filename string) (*Wallpaper, error) { + // WASM: no support yet for wallpapers. + if runtime.GOOS == "js" { + return &Wallpaper{ + Name: strings.Split(filepath.Base(filename), ".")[0], + ready: false, + }, nil + } + fh, err := os.Open(filename) if err != nil { return nil, err @@ -75,6 +88,7 @@ func FromFile(e render.Engine, filename string) (*Wallpaper, error) { Name: strings.Split(filepath.Base(filename), ".")[0], Format: format, Image: rgba, + ready: true, } wp.cache(e) return wp, nil diff --git a/wasm/Makefile b/wasm/Makefile new file mode 100644 index 0000000..d465abc --- /dev/null +++ b/wasm/Makefile @@ -0,0 +1,5 @@ +all: + GOOS=js GOARCH=wasm go build -v -o doodle.wasm + +clean: + rm -f doodle.wasm diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 0000000..06a9427 --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,51 @@ +# WebAssembly Port + +## Build and Test + +Change to the wasm/ folder and type `make` to build `doodle.wasm` + +To test it with a local Go server, cd to the wasm/ folder and run +`go run server.go` and visit http://localhost:8080/ + +Copy the `fonts` and `assets` folders from the project root to the +wasm/ directory so they're available over HTTP. + +## wasm_exec.js + +To update the wasm_exec.js to match your version of Go: + +```bash +# Fedora: install golang-misc +sudo dnf install golang-misc + +# Copy the wasm_exec.js +cp $(go env GOROOT)/misc/wasm/wasm_exec.js ./ +``` + +## Font Support + +Fonts are implemented as CSS embedded fonts configured in +`wasm/index.html` + +The font family name should match the filename, sans .ttf extension, +for example "DejaVuSans-Bold". Doodle's internal logic converts a +FontFilename string like "./fonts/DejaVuSans.ttf" into the base name +to use as the font family. It also has fallbacks for sans-serif and +serif in case of any problems. + +## Known Bugs and Limitations + +* github.com/kirsle/golog + * The detection of an interactive terminal is broken in WASM. + * `terminal.IsTerminal(int(os.Stdout.Fd()))` + * As a workaround, comment it out and hardcode to `false` +* Userdir + * For WASM we'll want to use localStorage to store user drawings + instead of the userdir. +* Wallpaper support + * WASM can't use os.Open to read the wallpaper image, so will need + another method to load the image. + * Texture caching support isn't implemented yet to hold the four + corner textures of a wallpaper. + * As a workaround, added a `wallpaper.ready` boolean and relevant + functions exit early for WASM so wallpapers don't render at all. diff --git a/wasm/index.html b/wasm/index.html new file mode 100644 index 0000000..5dda789 --- /dev/null +++ b/wasm/index.html @@ -0,0 +1,54 @@ + + + + Project: Doodle + + + + + + + + + + + diff --git a/wasm/main_wasm.go b/wasm/main_wasm.go new file mode 100644 index 0000000..7996f38 --- /dev/null +++ b/wasm/main_wasm.go @@ -0,0 +1,84 @@ +// +build js,wasm + +package main + +import ( + "fmt" + + "git.kirsle.net/apps/doodle/lib/render" + "git.kirsle.net/apps/doodle/lib/render/canvas" + doodle "git.kirsle.net/apps/doodle/pkg" + "git.kirsle.net/apps/doodle/pkg/branding" + "git.kirsle.net/apps/doodle/pkg/log" +) + +func main() { + fmt.Printf("Hello world\n") + // testRawCanvas() + + // HTML5 Canvas engine. + engine, _ := canvas.New("canvas") + engine.AddEventListeners() + + game := doodle.New(true, engine) + game.SetupEngine() + + // Manually inform Doodle of the canvas size since it can't control + // the size on its own. + w, h := engine.WindowSize() + game.SetWindowSize(w, h) + + // game.Goto(&doodle.GUITestScene{}) + // game.Goto(&doodle.EditorScene{}) + + game.Run() +} + +func testRawCanvas() { + engine, _ := canvas.New("canvas") + engine.SetTitle( + fmt.Sprintf("%s v%s", branding.AppName, branding.Version), + ) + fmt.Printf("Got engine: %+v\n", engine) + engine.Clear(render.Green) + + for pt := range render.IterLine2(render.NewPoint(20, 20), render.NewPoint(300, 300)) { + engine.DrawPoint(render.Red, pt) + } + + engine.DrawLine(render.Blue, render.NewPoint(20, 300), render.NewPoint(300, 20)) + + engine.DrawRect(render.Black, render.Rect{ + X: 5, + Y: 5, + W: 10, + H: 10, + }) + engine.DrawBox(render.White, render.Rect{ + X: 5, + Y: 5, + W: 10, + H: 10, + }) + + engine.DrawBox(render.Purple, render.Rect{ + X: 25, + Y: 5, + W: 10, + H: 10, + }) + + engine.DrawText(render.Text{ + Text: "Hello world!", + FontFilename: "DejaVuSans", + Size: 14, + }, render.NewPoint(400, 400)) + + size, _ := engine.ComputeTextRect(render.Text{ + Text: "Hello world! blah blah", + FontFilename: "DejaVuSans", + Size: 14, + }) + log.Info("text rect: %+v", size) + _ = engine +} diff --git a/wasm/server.go b/wasm/server.go new file mode 100644 index 0000000..48c64db --- /dev/null +++ b/wasm/server.go @@ -0,0 +1,128 @@ +// +build disabled + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "os/exec" + "path/filepath" + + "github.com/fsnotify/fsnotify" +) + +func main() { + const wasm = "/doodle.wasm" + + // Watch the dev directory for changes. + go watchChanges() + + http.Handle("/", http.FileServer(http.Dir("."))) + http.Handle("/fonts", http.FileServer(http.Dir("../fonts/"))) + http.HandleFunc(wasm, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/wasm") + http.ServeFile(w, r, "."+wasm) + }) + + fmt.Println("Listening at http://localhost:8080/") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// onChange handler to rebuild the wasm file automatically. +func onChange() { + out, err := exec.Command("make").Output() + if err != nil { + log.Printf("error: %s", err) + } + log.Printf("%s", out) + log.Printf("Doodle WASM file rebuilt") +} + +// Watch the Doodle source tree for changes to Go files and rebuild +// the wasm binary automatically. +func watchChanges() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("error: %s", err) + return + } + defer watcher.Close() + + done := make(chan bool) + go func() { + log.Println("Starting watch files loop") + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + log.Printf("event: %s", event) + if event.Op&fsnotify.Write == fsnotify.Write { + log.Printf("modified file: %s", event.Name) + onChange() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + + log.Printf("error: %s", err) + } + } + }() + + log.Println("Adding source directory to watcher") + dirs := crawlDirectory("../") + + // Watch all these folders. + for _, dir := range dirs { + err = watcher.Add(dir) + if err != nil { + log.Printf("error: %s", err) + } + } + <-done +} + +// Crawl the filesystem and return paths with Go files. +func crawlDirectory(root string) []string { + var ( + ext = ".go" + result []string + has bool + ) + + files, err := ioutil.ReadDir(root) + if err != nil { + log.Fatalln(err) + } + + for _, file := range files { + if file.Name() == ".git" { + continue + } + + // Recursively scan subdirectories. + if file.IsDir() { + result = append(result, + crawlDirectory(filepath.Join(root, file.Name()))..., + ) + continue + } + + // This root has a file we want? + if filepath.Ext(file.Name()) == ext { + has = true + } + } + + if has { + result = append(result, root) + } + + return result +} diff --git a/wasm/wasm_exec.js b/wasm/wasm_exec.js new file mode 100644 index 0000000..165d567 --- /dev/null +++ b/wasm/wasm_exec.js @@ -0,0 +1,465 @@ +// 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. + +(() => { + if (typeof global !== "undefined") { + // global already exists + } else if (typeof window !== "undefined") { + window.global = window; + } else if (typeof self !== "undefined") { + self.global = self; + } else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); + } + + // Map web browser API and Node.js API to a single common API (preferring web standards over Node.js API). + const isNodeJS = global.process && global.process.title === "node"; + if (isNodeJS) { + global.require = require; + global.fs = require("fs"); + + const nodeCrypto = require("crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + + const util = require("util"); + global.TextEncoder = util.TextEncoder; + global.TextDecoder = util.TextDecoder; + } else { + let outputBuf = ""; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + throw new Error("not implemented"); + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + open(path, flags, mode, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + read(fd, buffer, offset, length, position, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + fsync(fd, callback) { + callback(null); + }, + }; + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + global.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 mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.mem.buffer); + } + + const setInt64 = (addr, v) => { + mem().setUint32(addr + 0, v, true); + mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = mem().getUint32(addr + 0, true); + const high = mem().getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = mem().getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = mem().getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number") { + if (isNaN(v)) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 0, true); + return; + } + if (v === 0) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 1, true); + return; + } + mem().setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + mem().setFloat64(addr, 0, true); + return; + case null: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 2, true); + return; + case true: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 3, true); + return; + case false: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 4, true); + return; + } + + let ref = this._refs.get(v); + if (ref === undefined) { + ref = this._values.length; + this._values.push(v); + this._refs.set(v, ref); + } + let typeFlag = 0; + switch (typeof v) { + case "string": + typeFlag = 1; + break; + case "symbol": + typeFlag = 2; + break; + case "function": + typeFlag = 3; + break; + } + mem().setUint32(addr + 4, nanHead | typeFlag, true); + mem().setUint32(addr, ref, 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 timeOrigin = Date.now() - performance.now(); + this.importObject = { + go: { + // 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) => { + const code = mem().getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._refs; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = mem().getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func nanotime() int64 + "runtime.nanotime": (sp) => { + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { this._resume(); }, + getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + mem().setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + const id = mem().getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + 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) => { + 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(); // see comment above + storeValue(sp + 56, result); + mem().setUint8(sp + 64, 1); + } catch (err) { + storeValue(sp + 56, err); + mem().setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + 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) => { + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + this._inst = instance; + this._values = [ // TODO: garbage collection + NaN, + 0, + null, + true, + false, + global, + this._inst.exports.mem, + this, + ]; + this._refs = new Map(); + this.exited = false; + + const mem = new DataView(this._inst.exports.mem.buffer) + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + let ptr = offset; + new Uint8Array(mem.buffer, offset, str.length + 1).set(encoder.encode(str + "\0")); + offset += str.length + (8 - (str.length % 8)); + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + + const keys = Object.keys(this.env).sort(); + argvPtrs.push(keys.length); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + + const argv = offset; + argvPtrs.forEach((ptr) => { + mem.setUint32(offset, ptr, true); + mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + 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; + }; + } + } + + if (isNodeJS) { + if (process.argv.length < 3) { + process.stderr.write("usage: go_js_wasm_exec [wasm binary] [arguments]\n"); + process.exit(1); + } + + const go = new Go(); + go.argv = process.argv.slice(2); + go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); + go.exit = process.exit; + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { + process.on("exit", (code) => { // Node.js exits if no event handler is pending + if (code === 0 && !go.exited) { + // deadlock, make Go print error and stack traces + go._pendingEvent = { id: 0 }; + go._resume(); + } + }); + return go.run(result.instance); + }).catch((err) => { + throw err; + }); + } +})();