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!)
This commit is contained in:
Noah 2019-06-26 18:36:54 -07:00
parent 8d855582ed
commit af67b20d9b
22 changed files with 1222 additions and 9 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@ fonts/
maps/ maps/
bin/ bin/
dist/ dist/
wasm/assets/
*.wasm
*.doodad *.doodad
docker/ubuntu docker/ubuntu
docker/debian docker/debian

View File

@ -27,6 +27,8 @@ Makefile commands for Linux:
Build Tags below. Build Tags below.
* `make build-debug`: build a debug binary (not release-mode) to the `bin/` * `make build-debug`: build a debug binary (not release-mode) to the `bin/`
folder. See Build Tags below. 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 run`: run a local dev build of Doodle in debug mode
* `make guitest`: run a local dev build in the GUITest scene * `make guitest`: run a local dev build in the GUITest scene
* `make test`: run the test suite * `make test`: run the test suite

View File

@ -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/doodle cmd/doodle/main.go
go build $(LDFLAGS) -tags="developer" -i -o bin/doodad cmd/doodad/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. # `make install` to install the Go binaries to your GOPATH.
.PHONY: install .PHONY: install
install: install:

View File

@ -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()
}

66
lib/render/canvas/draw.go Normal file
View File

@ -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),
)
}

View File

@ -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
}

102
lib/render/canvas/events.go Normal file
View File

@ -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
}

88
lib/render/canvas/text.go Normal file
View File

@ -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
}

View File

@ -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 // ToColor converts a render.Color into a Go standard color.Color
func (c Color) ToColor() color.RGBA { func (c Color) ToColor() color.RGBA {
return color.RGBA{ return color.RGBA{

View File

@ -1,3 +1,5 @@
// +build !js
package ui package ui
import ( import (

View File

@ -64,6 +64,12 @@ func New(debug bool, engine render.Engine) *Doodle {
return d 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. // Title returns the game's preferred window title.
func (d *Doodle) Title() string { func (d *Doodle) Title() string {
return fmt.Sprintf("%s v%s", branding.AppName, branding.Version) return fmt.Sprintf("%s v%s", branding.AppName, branding.Version)

View File

@ -106,7 +106,7 @@ func (s *MainScene) Draw(d *Doodle) error {
label := ui.NewLabel(ui.Label{ label := ui.NewLabel(ui.Label{
Text: branding.AppName, Text: branding.AppName,
Font: render.Text{ Font: render.Text{
Size: 26, Size: 46,
Color: render.Pink, Color: render.Pink,
Stroke: render.SkyBlue, Stroke: render.SkyBlue,
Shadow: render.Black, Shadow: render.Black,
@ -122,7 +122,7 @@ func (s *MainScene) Draw(d *Doodle) error {
s.frame.Compute(d.Engine) s.frame.Compute(d.Engine)
s.frame.MoveTo(render.Point{ s.frame.MoveTo(render.Point{
X: (int32(d.width) / 2) - (s.frame.Size().W / 2), X: (int32(d.width) / 2) - (s.frame.Size().W / 2),
Y: 200, Y: 260,
}) })
s.frame.Present(d.Engine, s.frame.Point()) s.frame.Present(d.Engine, s.frame.Point())

View File

@ -3,6 +3,7 @@ package uix
import ( import (
"fmt" "fmt"
"os" "os"
"runtime"
"strings" "strings"
"git.kirsle.net/apps/doodle/lib/events" "git.kirsle.net/apps/doodle/lib/events"
@ -127,10 +128,12 @@ func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) {
// TODO: wallpaper paths // TODO: wallpaper paths
filename := "assets/wallpapers/" + level.Wallpaper filename := "assets/wallpapers/" + level.Wallpaper
if runtime.GOOS != "js" {
if _, err := os.Stat(filename); os.IsNotExist(err) { if _, err := os.Stat(filename); os.IsNotExist(err) {
log.Error("LoadLevel: %s", err) log.Error("LoadLevel: %s", err)
filename = "assets/wallpapers/notebook.png" // XXX TODO filename = "assets/wallpapers/notebook.png" // XXX TODO
} }
}
wp, err := wallpaper.FromFile(e, filename) wp, err := wallpaper.FromFile(e, filename)
if err != nil { if err != nil {

View File

@ -4,6 +4,7 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"github.com/kirsle/configdir" "github.com/kirsle/configdir"
@ -38,10 +39,13 @@ func init() {
FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts") FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts")
// Ensure all the directories exist. // Ensure all the directories exist.
// WASM: do not make paths in wasm.
if runtime.GOOS != "js" {
configdir.MakePath(LevelDirectory) configdir.MakePath(LevelDirectory)
configdir.MakePath(DoodadDirectory) configdir.MakePath(DoodadDirectory)
configdir.MakePath(FontDirectory) configdir.MakePath(FontDirectory)
} }
}
// LevelPath will turn a "simple" filename into an absolute path in the user's // LevelPath will turn a "simple" filename into an absolute path in the user's
// local levels folder. If the filename already contains slashes, it is returned // local levels folder. If the filename already contains slashes, it is returned

View File

@ -3,6 +3,7 @@ package wallpaper
// The methods that deal in cached Textures for Doodle. // The methods that deal in cached Textures for Doodle.
import ( import (
"errors"
"fmt" "fmt"
"image" "image"
"os" "os"
@ -14,7 +15,10 @@ import (
// CornerTexture returns the Texture. // CornerTexture returns the Texture.
func (wp *Wallpaper) CornerTexture(e render.Engine) (render.Texturer, error) { 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 { if wp.tex.corner == nil {
tex, err := texture(e, wp.corner, wp.Name+"c") tex, err := texture(e, wp.corner, wp.Name+"c")
wp.tex.corner = tex wp.tex.corner = tex

View File

@ -5,6 +5,7 @@ import (
"image/draw" "image/draw"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/render"
@ -16,6 +17,10 @@ type Wallpaper struct {
Format string // image file format Format string // image file format
Image *image.RGBA 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. // Parsed values.
quarterWidth int quarterWidth int
quarterHeight 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 // If the renger.Engine is nil it will compute images but not pre-cache any
// textures yet. // textures yet.
func FromFile(e render.Engine, filename string) (*Wallpaper, error) { 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) fh, err := os.Open(filename)
if err != nil { if err != nil {
return nil, err return nil, err
@ -75,6 +88,7 @@ func FromFile(e render.Engine, filename string) (*Wallpaper, error) {
Name: strings.Split(filepath.Base(filename), ".")[0], Name: strings.Split(filepath.Base(filename), ".")[0],
Format: format, Format: format,
Image: rgba, Image: rgba,
ready: true,
} }
wp.cache(e) wp.cache(e)
return wp, nil return wp, nil

5
wasm/Makefile Normal file
View File

@ -0,0 +1,5 @@
all:
GOOS=js GOARCH=wasm go build -v -o doodle.wasm
clean:
rm -f doodle.wasm

51
wasm/README.md Normal file
View File

@ -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.

54
wasm/index.html Normal file
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<title>Project: Doodle</title>
<style>
#canvas {
position: fixed;
width: 100%;
height: 100%;
top: 0;
right: 0;
left: 0;
bottom: 0;
}
@font-face {
font-family: "DejaVuSansMono";
src: url("fonts/DejaVuSansMono.ttf") format("truetype");
}
@font-face {
font-family: "DejaVuSans";
src: url("fonts/DejaVuSans.ttf") format("truetype");
}
@font-face {
font-family: "DejaVuSans-Bold";
src: url("fonts/DejaVuSans-Bold.ttf") format("truetype");
}
</style>
</head>
<body>
<script src="wasm_exec.js"></script>
<script>
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
(function() {
const go = new Go();
WebAssembly.instantiateStreaming(fetch("doodle.wasm"), go.importObject).then(result => {
console.clear();
go.run(result.instance);
WebAssembly.instantiate(result.module, go.importObject); // reset instance
})
})();
</script>
<canvas id="canvas"></canvas>
</body>
</html>

84
wasm/main_wasm.go Normal file
View File

@ -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
}

128
wasm/server.go Normal file
View File

@ -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
}

465
wasm/wasm_exec.js Normal file
View File

@ -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;
});
}
})();