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