From a92ceac0587476b694ea010a82b8d9f24a7ef83e Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 27 Sep 2019 10:30:35 -0700 Subject: [PATCH] Initial commit --- render/canvas/canvas.go | 39 ++++++ render/canvas/draw.go | 77 +++++++++++ render/canvas/engine.go | 86 ++++++++++++ render/canvas/events.go | 227 ++++++++++++++++++++++++++++++++ render/canvas/text.go | 88 +++++++++++++ render/canvas/texture.go | 91 +++++++++++++ render/color.go | 274 +++++++++++++++++++++++++++++++++++++++ render/ellipse.go | 64 +++++++++ render/functions.go | 108 +++++++++++++++ render/interface.go | 196 ++++++++++++++++++++++++++++ render/point.go | 109 ++++++++++++++++ render/point_test.go | 60 +++++++++ render/rect_test.go | 81 ++++++++++++ render/sdl/canvas.go | 57 ++++++++ render/sdl/events.go | 171 ++++++++++++++++++++++++ render/sdl/sdl.go | 120 +++++++++++++++++ render/sdl/text.go | 199 ++++++++++++++++++++++++++++ render/sdl/texture.go | 84 ++++++++++++ render/sdl/utils.go | 26 ++++ render/shapes.go | 105 +++++++++++++++ 20 files changed, 2262 insertions(+) create mode 100644 render/canvas/canvas.go create mode 100644 render/canvas/draw.go create mode 100644 render/canvas/engine.go create mode 100644 render/canvas/events.go create mode 100644 render/canvas/text.go create mode 100644 render/canvas/texture.go create mode 100644 render/color.go create mode 100644 render/ellipse.go create mode 100644 render/functions.go create mode 100644 render/interface.go create mode 100644 render/point.go create mode 100644 render/point_test.go create mode 100644 render/rect_test.go create mode 100644 render/sdl/canvas.go create mode 100644 render/sdl/events.go create mode 100644 render/sdl/sdl.go create mode 100644 render/sdl/text.go create mode 100644 render/sdl/texture.go create mode 100644 render/sdl/utils.go create mode 100644 render/shapes.go diff --git a/render/canvas/canvas.go b/render/canvas/canvas.go new file mode 100644 index 0000000..e492099 --- /dev/null +++ b/render/canvas/canvas.go @@ -0,0 +1,39 @@ +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 js.Global().Get("window").Get("innerWidth").Int() + // return c.Value.Get("clientWidth").Int() +} + +// ClientH returns the client height. +func (c Canvas) ClientH() int { + return js.Global().Get("window").Get("innerHeight").Int() + // return c.Value.Get("clientHeight").Int() +} diff --git a/render/canvas/draw.go b/render/canvas/draw.go new file mode 100644 index 0000000..f16381c --- /dev/null +++ b/render/canvas/draw.go @@ -0,0 +1,77 @@ +package canvas + +import ( + "fmt" + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// Methods here implement the drawing functions of the render.Engine + +// RGBA turns a color into CSS RGBA string. +func RGBA(c render.Color) string { + return fmt.Sprintf("rgba(%d,%d,%d,%f)", + c.Red, + c.Green, + c.Blue, + float64(c.Alpha)/255, + ) +} + +// Clear the canvas to a certain color. +func (e *Engine) Clear(color render.Color) { + e.canvas.ctx2d.Set("fillStyle", RGBA(color)) + 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", RGBA(color)) + 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", RGBA(color)) + for pt := range render.IterLine(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", RGBA(color)) + 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", RGBA(color)) + e.canvas.ctx2d.Call("fillRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} diff --git a/render/canvas/engine.go b/render/canvas/engine.go new file mode 100644 index 0000000..5f7f49e --- /dev/null +++ b/render/canvas/engine.go @@ -0,0 +1,86 @@ +package canvas + +import ( + "syscall/js" + "time" + + "git.kirsle.net/apps/doodle/lib/events" +) + +// 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 + textures map[string]*Texture // cached texture PNG images + + // Event channel. WASM subscribes to events asynchronously using the + // JavaScript APIs, whereas SDL2 polls the event queue which orders them + // all up for processing. This channel will order and queue the events. + queue chan Event +} + +// 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(), + queue: make(chan Event, 1024), + textures: map[string]*Texture{}, + } + + return engine, nil +} + +// WindowSize returns the size of the canvas window. +func (e *Engine) WindowSize() (w, h int) { + // Good time to recompute it first? + var ( + window = js.Global().Get("window") + width = window.Get("innerWidth").Int() + height = window.Get("innerHeight").Int() + ) + e.canvas.Value.Set("width", width) + e.canvas.Value.Set("height", height) + return e.canvas.ClientW(), e.canvas.ClientH() +} + +// GetTicks returns the number of milliseconds since the engine started. +func (e *Engine) GetTicks() uint32 { + ms := time.Since(e.startTime) * time.Millisecond + return uint32(ms) +} + +// TO BE IMPLEMENTED... + +func (e *Engine) Setup() error { + return nil +} + +func (e *Engine) Present() error { + return nil +} + +// 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/render/canvas/events.go b/render/canvas/events.go new file mode 100644 index 0000000..4cc4905 --- /dev/null +++ b/render/canvas/events.go @@ -0,0 +1,227 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/events" +) + +// EventClass to categorize JavaScript events. +type EventClass int + +// EventClass values. +const ( + MouseEvent EventClass = iota + ClickEvent + KeyEvent + ResizeEvent + WindowEvent +) + +// Event object queues up asynchronous JavaScript events to be processed linearly. +type Event struct { + Name string // mouseup, keydown, etc. + Class EventClass + + // Mouse events. + X int + Y int + LeftClick bool + RightClick bool + + // Key events. + KeyName string + State bool + Repeat bool +} + +// AddEventListeners sets up bindings to collect events from the browser. +func (e *Engine) AddEventListeners() { + // Window resize. + js.Global().Get("window").Call( + "addEventListener", + "resize", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + e.queue <- Event{ + Name: "resize", + Class: WindowEvent, + } + return nil + }), + ) + + // 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() + ) + + e.queue <- Event{ + Name: "mousemove", + Class: MouseEvent, + X: x, + Y: 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() + ) + + // Is a mouse button pressed down? + checkDown := func(number int) bool { + if which == number { + return ev == "mousedown" + } + return false + } + + e.queue <- Event{ + Name: ev, + Class: ClickEvent, + X: x, + Y: y, + LeftClick: checkDown(1), + RightClick: 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 + for _, ev := range []string{"keydown", "keyup"} { + ev := ev + js.Global().Get("document").Call( + "addEventListener", + ev, + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + var ( + event = args[0] + key = event.Get("key").String() + repeat = event.Get("repeat").Bool() + + pressed = ev == "keydown" + ) + + if key == "F3" { + args[0].Call("preventDefault") + } + + e.queue <- Event{ + Name: ev, + Class: KeyEvent, + KeyName: key, + Repeat: repeat, + State: pressed, + } + return nil + }), + ) + } +} + +// PollEvent returns the next event in the queue, or null. +func (e *Engine) PollEvent() *Event { + select { + case event := <-e.queue: + return &event + default: + return nil + } + return nil +} + +// Poll for events. +func (e *Engine) Poll() (*events.State, error) { + s := e.events + + for event := e.PollEvent(); event != nil; event = e.PollEvent() { + switch event.Class { + case WindowEvent: + s.Resized.Push(true) + case MouseEvent: + s.CursorX.Push(int32(event.X)) + s.CursorY.Push(int32(event.Y)) + case ClickEvent: + s.CursorX.Push(int32(event.X)) + s.CursorY.Push(int32(event.Y)) + s.Button1.Push(event.LeftClick) + s.Button2.Push(event.RightClick) + case KeyEvent: + switch event.KeyName { + case "Escape": + if event.Repeat { + continue + } + + if event.State { + s.EscapeKey.Push(true) + } + case "Enter": + if event.Repeat { + continue + } + + if event.State { + s.EnterKey.Push(true) + } + case "F3": + if event.State { + s.KeyName.Push("F3") + } + case "ArrowUp": + s.Up.Push(event.State) + case "ArrowLeft": + s.Left.Push(event.State) + case "ArrowRight": + s.Right.Push(event.State) + case "ArrowDown": + s.Down.Push(event.State) + case "Shift": + s.ShiftActive.Push(event.State) + continue + case "Alt": + case "Control": + continue + case "Backspace": + if event.State { + s.KeyName.Push(`\b`) + } + default: + if event.State { + s.KeyName.Push(event.KeyName) + } else { + s.KeyName.Push("") + } + } + } + } + + return e.events, nil +} diff --git a/render/canvas/text.go b/render/canvas/text.go new file mode 100644 index 0000000..a4ce4aa --- /dev/null +++ b/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/render/canvas/texture.go b/render/canvas/texture.go new file mode 100644 index 0000000..be6da8a --- /dev/null +++ b/render/canvas/texture.go @@ -0,0 +1,91 @@ +package canvas + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + "image/png" + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// Texture can hold on to cached image textures. +type Texture struct { + data string // data:image/png URI + image js.Value // DOM image element + canvas js.Value // Warmed up canvas element + ctx2d js.Value // 2D drawing context for the canvas. + width int + height int +} + +// StoreTexture caches a texture from a bitmap. +func (e *Engine) StoreTexture(name string, img image.Image) (render.Texturer, error) { + var ( + fh = bytes.NewBuffer([]byte{}) + imageSize = img.Bounds().Size() + width = imageSize.X + height = imageSize.Y + ) + + // Encode to PNG format. + if err := png.Encode(fh, img); err != nil { + return nil, err + } + + var dataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(fh.Bytes()) + + tex := &Texture{ + data: dataURI, + width: width, + height: height, + } + + // Preheat a cached Canvas object. + canvas := js.Global().Get("document").Call("createElement", "canvas") + canvas.Set("width", width) + canvas.Set("height", height) + tex.canvas = canvas + + ctx2d := canvas.Call("getContext", "2d") + tex.ctx2d = ctx2d + + // Load as a JS Image object. + image := js.Global().Call("eval", "new Image()") + image.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + ctx2d.Call("drawImage", image, 0, 0) + return nil + })) + image.Set("src", tex.data) + tex.image = image + + // Cache the texture in memory. + e.textures[name] = tex + + return tex, nil +} + +// Size returns the dimensions of the texture. +func (t *Texture) Size() render.Rect { + return render.NewRect(int32(t.width), int32(t.height)) +} + +// LoadTexture recalls a cached texture image. +func (e *Engine) LoadTexture(name string) (render.Texturer, error) { + if tex, ok := e.textures[name]; ok { + return tex, nil + } + + return nil, errors.New("no bitmap data stored for " + name) +} + +// Copy a texturer bitmap onto the canvas. +func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) { + tex := t.(*Texture) + + // e.canvas.ctx2d.Call("drawImage", tex.image, dist.X, dist.Y) + e.canvas.ctx2d.Call("drawImage", tex.canvas, dist.X, dist.Y) + +} diff --git a/render/color.go b/render/color.go new file mode 100644 index 0000000..e6b045d --- /dev/null +++ b/render/color.go @@ -0,0 +1,274 @@ +package render + +import ( + "encoding/json" + "errors" + "fmt" + "image/color" + "regexp" + "strconv" + + "github.com/vmihailenco/msgpack" +) + +var ( + // Regexps to parse hex color codes. Three formats are supported: + // * reHexColor3 uses only 3 hex characters, like #F90 + // * reHexColor6 uses standard 6 characters, like #FF9900 + // * reHexColor8 is the standard 6 plus alpha channel, like #FF9900FF + reHexColor3 = regexp.MustCompile(`^([A-Fa-f0-9])([A-Fa-f0-9])([A-Fa-f0-9])$`) + reHexColor6 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`) + reHexColor8 = regexp.MustCompile(`^([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})$`) +) + +// Color holds an RGBA color value. +type Color struct { + Red uint8 + Green uint8 + Blue uint8 + Alpha uint8 +} + +// RGBA creates a new Color. +func RGBA(r, g, b, a uint8) Color { + return Color{ + Red: r, + Green: g, + Blue: b, + Alpha: a, + } +} + +// FromColor creates a render.Color from a Go color.Color +func FromColor(from color.Color) Color { + // downscale a 16-bit color value to 8-bit. input range 0x0000..0xffff + downscale := func(in uint32) uint8 { + var scale = float64(in) / 0xffff + return uint8(scale * 0xff) + } + r, g, b, a := from.RGBA() + return RGBA( + downscale(r), + downscale(g), + downscale(b), + downscale(a), + ) +} + +// MustHexColor parses a color from hex code or panics. +func MustHexColor(hex string) Color { + color, err := HexColor(hex) + if err != nil { + panic(err) + } + return color +} + +// HexColor parses a color from hexadecimal code. +func HexColor(hex string) (Color, error) { + c := Black // default color + + if len(hex) > 0 && hex[0] == '#' { + hex = hex[1:] + } + + var m []string + if len(hex) == 3 { + m = reHexColor3.FindStringSubmatch(hex) + } else if len(hex) == 6 { + m = reHexColor6.FindStringSubmatch(hex) + } else if len(hex) == 8 { + m = reHexColor8.FindStringSubmatch(hex) + } else { + return c, errors.New("not a valid length for color code; only 3, 6 and 8 supported") + } + + // Any luck? + if m == nil { + return c, errors.New("not a valid hex color code") + } + + // Parse the color values. 16=base, 8=bit size + red, _ := strconv.ParseUint(m[1], 16, 8) + green, _ := strconv.ParseUint(m[2], 16, 8) + blue, _ := strconv.ParseUint(m[3], 16, 8) + + // Alpha channel available? + var alpha uint64 = 255 + if len(m) == 5 { + alpha, _ = strconv.ParseUint(m[4], 16, 8) + } + + c.Red = uint8(red) + c.Green = uint8(green) + c.Blue = uint8(blue) + c.Alpha = uint8(alpha) + return c, nil +} + +func (c Color) String() string { + return fmt.Sprintf( + "Color<#%02x%02x%02x+%02x>", + c.Red, c.Green, c.Blue, c.Alpha, + ) +} + +// 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{ + R: c.Red, + G: c.Green, + B: c.Blue, + A: c.Alpha, + } +} + +// Transparent returns whether the alpha channel is zeroed out and the pixel +// won't appear as anything when rendered. +func (c Color) Transparent() bool { + return c.Alpha == 0x00 +} + +// MarshalJSON serializes the Color for JSON. +func (c Color) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf( + `"#%02x%02x%02x"`, + c.Red, c.Green, c.Blue, + )), nil +} + +// UnmarshalJSON reloads the Color from JSON. +func (c *Color) UnmarshalJSON(b []byte) error { + var hex string + err := json.Unmarshal(b, &hex) + if err != nil { + return err + } + + parsed, err := HexColor(hex) + if err != nil { + return err + } + + c.Red = parsed.Red + c.Blue = parsed.Blue + c.Green = parsed.Green + c.Alpha = parsed.Alpha + return nil +} + +func (c Color) EncodeMsgpack(enc *msgpack.Encoder) error { + return enc.EncodeString(fmt.Sprintf( + `"#%02x%02x%02x"`, + c.Red, c.Green, c.Blue, + )) +} + +func (c Color) DecodeMsgpack(dec *msgpack.Decoder) error { + hex, err := dec.DecodeString() + if err != nil { + return fmt.Errorf("Color.DecodeMsgpack: %s", err) + } + + parsed, err := HexColor(hex) + if err != nil { + return fmt.Errorf("Color.DecodeMsgpack: HexColor: %s", err) + } + + c.Red = parsed.Red + c.Blue = parsed.Blue + c.Green = parsed.Green + c.Alpha = parsed.Alpha + return nil +} + +// // MarshalMsgpack serializes the Color for msgpack. +// func (c Color) MarshalMsgpack() ([]byte, error) { +// data := []uint8{ +// c.Red, c.Green, c.Blue, c.Alpha, +// } +// return msgpack.Marshal(data) +// } +// +// // UnmarshalMsgpack decodes a Color from msgpack format. +// func (c *Color) UnmarshalMsgpack(b []byte) error { +// var data []uint8 +// if err := msgpack.Unmarshal(data, b); err != nil { +// return err +// } +// c.Red = 255 +// c.Green = data[1] +// c.Blue = data[2] +// c.Alpha = data[3] +// return nil +// } + +// IsZero returns if the color is all zeroes (invisible). +func (c Color) IsZero() bool { + return c.Red+c.Green+c.Blue+c.Alpha == 0 +} + +// Add a relative color value to the color. +func (c Color) Add(r, g, b, a int) Color { + var ( + R = int(c.Red) + r + G = int(c.Green) + g + B = int(c.Blue) + b + A = int(c.Alpha) + a + ) + + cap8 := func(v int) uint8 { + if v > 255 { + v = 255 + } else if v < 0 { + v = 0 + } + return uint8(v) + } + + return Color{ + Red: cap8(R), + Green: cap8(G), + Blue: cap8(B), + Alpha: cap8(A), + } +} + +// AddColor adds another Color to your Color. +func (c Color) AddColor(other Color) Color { + return c.Add( + int(other.Red), + int(other.Green), + int(other.Blue), + int(other.Alpha), + ) +} + +// Lighten a color value. +func (c Color) Lighten(v int) Color { + return c.Add(v, v, v, 0) +} + +// Darken a color value. +func (c Color) Darken(v int) Color { + return c.Add(-v, -v, -v, 0) +} + +// Transparentize adjusts the alpha value. +func (c Color) Transparentize(v int) Color { + return c.Add(0, 0, 0, v) +} + +// SetAlpha sets the alpha value to a specific setting. +func (c Color) SetAlpha(v uint8) Color { + c.Alpha = v + return c +} diff --git a/render/ellipse.go b/render/ellipse.go new file mode 100644 index 0000000..d259f3c --- /dev/null +++ b/render/ellipse.go @@ -0,0 +1,64 @@ +package render + +// MidpointEllipse implements an ellipse plotting algorithm. +func MidpointEllipse(center, radius Point) chan Point { + yield := make(chan Point) + go func() { + + var ( + pos = NewPoint(radius.X, 0) + delta = NewPoint( + 2*radius.Y*radius.Y*pos.X, + 2*radius.X*radius.X*pos.Y, + ) + err = radius.X*radius.X - + radius.Y*radius.Y*radius.X + + (radius.Y*radius.Y)/4 + ) + + for delta.Y < delta.X { + yield <- NewPoint(center.X+pos.X, center.Y+pos.Y) + yield <- NewPoint(center.X+pos.X, center.Y-pos.Y) + yield <- NewPoint(center.X-pos.X, center.Y+pos.Y) + yield <- NewPoint(center.X-pos.X, center.Y-pos.Y) + + pos.Y++ + + if err < 0 { + delta.Y += 2 * radius.X * radius.X + err += delta.Y + radius.X*radius.X + } else { + pos.X-- + delta.Y += 2 * radius.X * radius.X + delta.X -= 2 * radius.Y * radius.Y + err += delta.Y - delta.X + radius.X*radius.X + } + } + + err = radius.X*radius.X*(pos.Y*pos.Y+pos.Y) + + radius.Y*radius.Y*(pos.X-1)*(pos.X-1) - + radius.Y*radius.Y*radius.X*radius.X + + for pos.X >= 0 { + yield <- NewPoint(center.X+pos.X, center.Y+pos.Y) + yield <- NewPoint(center.X+pos.X, center.Y-pos.Y) + yield <- NewPoint(center.X-pos.X, center.Y+pos.Y) + yield <- NewPoint(center.X-pos.X, center.Y-pos.Y) + + pos.X-- + + if err > 0 { + delta.X -= 2 * radius.Y * radius.Y + err += radius.Y*radius.Y - delta.X + } else { + pos.Y++ + delta.Y += 2 * radius.X * radius.X + delta.X -= 2 * radius.Y * radius.Y + err += delta.Y - delta.X + radius.Y*radius.Y + } + } + + close(yield) + }() + return yield +} diff --git a/render/functions.go b/render/functions.go new file mode 100644 index 0000000..7b719a3 --- /dev/null +++ b/render/functions.go @@ -0,0 +1,108 @@ +package render + +import ( + "fmt" + "regexp" + "strconv" +) + +var regexpResolution = regexp.MustCompile(`^(\d+)x(\d+)$`) + +// ParseResolution turns a resolution string like "1024x768" and returns the +// width and height values. +func ParseResolution(resi string) (int, int, error) { + m := regexpResolution.FindStringSubmatch(resi) + if m == nil { + return 0, 0, fmt.Errorf("invalid resolution format, should be %s", + regexpResolution.String(), + ) + } + + width, err := strconv.Atoi(m[1]) + if err != nil { + return 0, 0, err + } + + height, err := strconv.Atoi(m[2]) + if err != nil { + return 0, 0, err + } + + return width, height, nil +} + +// TrimBox helps with Engine.Copy() to trim a destination box so that it +// won't overflow with the parent container. +func TrimBox(src, dst *Rect, p Point, S Rect, thickness int32) { + // Constrain source width to not bigger than Canvas width. + if src.W > S.W { + src.W = S.W + } + if src.H > S.H { + src.H = S.H + } + + // If the destination width will cause it to overflow the widget + // box, trim off the right edge of the destination rect. + // + // Keep in mind we're dealing with chunks here, and a chunk is + // a small part of the image. Example: + // - Canvas is 800x600 (S.W=800 S.H=600) + // - Chunk wants to render at 790,0 width 100,100 or whatever + // dst={790, 0, 100, 100} + // - Chunk box would exceed 800px width (X=790 + W=100 == 890) + // - Find the delta how much it exceeds as negative (800 - 890 == -90) + // - Lower the Source and Dest rects by that delta size so they + // stay proportional and don't scale or anything dumb. + if dst.X+src.W > p.X+S.W { + // NOTE: delta is a negative number, + // so it will subtract from the width. + delta := (p.X + S.W - thickness) - (dst.W + dst.X) + src.W += delta + dst.W += delta + } + if dst.Y+src.H > p.Y+S.H { + // NOTE: delta is a negative number + delta := (p.Y + S.H - thickness) - (dst.H + dst.Y) + src.H += delta + dst.H += delta + } + + // The same for the top left edge, so the drawings don't overlap + // menu bars or left side toolbars. + // - Canvas was placed 80px from the left of the screen. + // Canvas.MoveTo(80, 0) + // - A texture wants to draw at 60, 0 which would cause it to + // overlap 20 pixels into the left toolbar. It needs to be cropped. + // - The delta is: p.X=80 - dst.X=60 == 20 + // - Set destination X to p.X to constrain it there: 20 + // - Subtract the delta from destination W so we don't scale it. + // - Add 20 to X of the source: the left edge of source is not visible + if dst.X < p.X { + // NOTE: delta is a positive number, + // so it will add to the destination coordinates. + delta := p.X - dst.X + dst.X = p.X + thickness + dst.W -= delta + src.X += delta + } + if dst.Y < p.Y { + delta := p.Y - dst.Y + dst.Y = p.Y + thickness + dst.H -= delta + src.Y += delta + } + + // Trim the destination width so it doesn't overlap the Canvas border. + if dst.W >= S.W-thickness { + dst.W = S.W - thickness + } +} + +// AbsInt32 returns the absolute value of an int32. +func AbsInt32(v int32) int32 { + if v < 0 { + return -v + } + return v +} diff --git a/render/interface.go b/render/interface.go new file mode 100644 index 0000000..c30948a --- /dev/null +++ b/render/interface.go @@ -0,0 +1,196 @@ +package render + +import ( + "fmt" + "image" + + "git.kirsle.net/apps/doodle/lib/events" +) + +// Engine is the interface for the rendering engine, keeping SDL-specific stuff +// far away from the core of Doodle. +type Engine interface { + Setup() error + + // Poll for events like keypresses and mouse clicks. + Poll() (*events.State, error) + GetTicks() uint32 + WindowSize() (w, h int) + + // Present presents the current state to the screen. + Present() error + + // Clear the full canvas and set this color. + Clear(Color) + SetTitle(string) + DrawPoint(Color, Point) + DrawLine(Color, Point, Point) + DrawRect(Color, Rect) + DrawBox(Color, Rect) + DrawText(Text, Point) error + ComputeTextRect(Text) (Rect, error) + + // Texture caching. + StoreTexture(name string, img image.Image) (Texturer, error) + LoadTexture(name string) (Texturer, error) + Copy(t Texturer, src, dst Rect) + + // Delay for a moment using the render engine's delay method, + // implemented by sdl.Delay(uint32) + Delay(uint32) + + // Tasks that the Setup function should defer until tear-down. + Teardown() + + Loop() error // maybe? +} + +// Texturer is a stored image texture used by the rendering engine while +// abstracting away its inner workings. +type Texturer interface { + Size() Rect +} + +// Rect has a coordinate and a width and height. +type Rect struct { + X int32 + Y int32 + W int32 + H int32 +} + +// NewRect creates a rectangle of size `width` and `height`. The X,Y values +// are initialized to zero. +func NewRect(width, height int32) Rect { + return Rect{ + W: width, + H: height, + } +} + +func (r Rect) String() string { + return fmt.Sprintf("Rect<%d,%d,%d,%d>", + r.X, r.Y, r.W, r.H, + ) +} + +// Point returns the rectangle's X,Y values as a Point. +func (r Rect) Point() Point { + return Point{ + X: r.X, + Y: r.Y, + } +} + +// Bigger returns if the given rect is larger than the current one. +func (r Rect) Bigger(other Rect) bool { + // TODO: don't know why this is ! + return !(other.X < r.X || // Lefter + other.Y < r.Y || // Higher + other.W > r.W || // Wider + other.H > r.H) // Taller +} + +// Intersects with the other rectangle in any way. +func (r Rect) Intersects(other Rect) bool { + // Do a bidirectional compare. + compare := func(a, b Rect) bool { + var corners = []Point{ + NewPoint(b.X, b.Y), + NewPoint(b.X, b.Y+b.H), + NewPoint(b.X+b.W, b.Y), + NewPoint(b.X+b.W, b.Y+b.H), + } + for _, pt := range corners { + if pt.Inside(a) { + return true + } + } + return false + } + + return compare(r, other) || compare(other, r) || false +} + +// IsZero returns if the Rect is uninitialized. +func (r Rect) IsZero() bool { + return r.X == 0 && r.Y == 0 && r.W == 0 && r.H == 0 +} + +// Add another rect. +func (r Rect) Add(other Rect) Rect { + return Rect{ + X: r.X + other.X, + Y: r.Y + other.Y, + W: r.W + other.W, + H: r.H + other.H, + } +} + +// Add a point to move the rect. +func (r Rect) AddPoint(other Point) Rect { + return Rect{ + X: r.X + other.X, + Y: r.Y + other.Y, + W: r.W, + H: r.H, + } +} + +// SubtractPoint is the inverse of AddPoint. Use this only if you need to invert +// the Point being added. +// +// This does r.X - other.X, r.Y - other.Y and keeps the width/height the same. +func (r Rect) SubtractPoint(other Point) Rect { + return Rect{ + X: r.X - other.X, + Y: r.Y - other.Y, + W: r.W, + H: r.H, + } +} + +// Text holds information for drawing text. +type Text struct { + Text string + Size int + Color Color + Padding int32 + PadX int32 + PadY int32 + Stroke Color // Stroke color (if not zero) + Shadow Color // Drop shadow color (if not zero) + FontFilename string // Path to *.ttf file on disk +} + +func (t Text) String() string { + return fmt.Sprintf(`Text<"%s" %dpx %s>`, t.Text, t.Size, t.Color) +} + +// IsZero returns if the Text is the zero value. +func (t Text) IsZero() bool { + return t.Text == "" && t.Size == 0 && t.Color == Invisible && t.Padding == 0 && t.Stroke == Invisible && t.Shadow == Invisible +} + +// Common color names. +var ( + Invisible = Color{} + White = RGBA(255, 255, 255, 255) + Grey = RGBA(153, 153, 153, 255) + Black = RGBA(0, 0, 0, 255) + SkyBlue = RGBA(0, 153, 255, 255) + Blue = RGBA(0, 0, 255, 255) + DarkBlue = RGBA(0, 0, 153, 255) + Red = RGBA(255, 0, 0, 255) + DarkRed = RGBA(153, 0, 0, 255) + Green = RGBA(0, 255, 0, 255) + DarkGreen = RGBA(0, 153, 0, 255) + Cyan = RGBA(0, 255, 255, 255) + DarkCyan = RGBA(0, 153, 153, 255) + Yellow = RGBA(255, 255, 0, 255) + Orange = RGBA(255, 153, 0, 255) + DarkYellow = RGBA(153, 153, 0, 255) + Magenta = RGBA(255, 0, 255, 255) + Purple = RGBA(153, 0, 153, 255) + Pink = RGBA(255, 153, 255, 255) +) diff --git a/render/point.go b/render/point.go new file mode 100644 index 0000000..6ef757d --- /dev/null +++ b/render/point.go @@ -0,0 +1,109 @@ +package render + +import ( + "fmt" + "strconv" + "strings" +) + +// Point holds an X,Y coordinate value. +type Point struct { + X int32 + Y int32 +} + +// Common points. +var ( + Origin Point +) + +// NewPoint makes a new Point at an X,Y coordinate. +func NewPoint(x, y int32) Point { + return Point{ + X: x, + Y: y, + } +} + +func (p Point) String() string { + return fmt.Sprintf("%d,%d", p.X, p.Y) +} + +// ParsePoint to parse a point from its string representation. +func ParsePoint(v string) (Point, error) { + halves := strings.Split(v, ",") + if len(halves) != 2 { + return Point{}, fmt.Errorf("'%s': not a valid coordinate string", v) + } + x, errX := strconv.Atoi(halves[0]) + y, errY := strconv.Atoi(halves[1]) + if errX != nil || errY != nil { + return Point{}, fmt.Errorf("invalid coordinate string (X: %v; Y: %v)", + errX, + errY, + ) + } + return Point{ + X: int32(x), + Y: int32(y), + }, nil +} + +// IsZero returns if the point is the zero value. +func (p Point) IsZero() bool { + return p.X == 0 && p.Y == 0 +} + +// Inside returns whether the Point falls inside the rect. +// +// NOTICE: the W and H are zero-relative, so a 100x100 box at coordinate +// X,Y would still have W,H of 100. +func (p Point) Inside(r Rect) bool { + var ( + x1 = r.X + y1 = r.Y + x2 = r.X + r.W + y2 = r.Y + r.H + ) + return ((p.X >= x1 && p.X <= x2) && + (p.Y >= y1 && p.Y <= y2)) +} + +// Add (or subtract) the other point to your current point. +func (p *Point) Add(other Point) { + p.X += other.X + p.Y += other.Y +} + +// Subtract the other point from your current point. +func (p *Point) Subtract(other Point) { + p.X -= other.X + p.Y -= other.Y +} + +// MarshalText to convert the point into text so that a render.Point may be used +// as a map key and serialized to JSON. +func (p *Point) MarshalText() ([]byte, error) { + return []byte(fmt.Sprintf("%d,%d", p.X, p.Y)), nil +} + +// UnmarshalText to restore it from text. +func (p *Point) UnmarshalText(b []byte) error { + halves := strings.Split(strings.Trim(string(b), `"`), ",") + if len(halves) != 2 { + return fmt.Errorf("'%s': not a valid coordinate string", b) + } + + x, errX := strconv.Atoi(halves[0]) + y, errY := strconv.Atoi(halves[1]) + if errX != nil || errY != nil { + return fmt.Errorf("Point.UnmarshalJSON: Atoi errors (X=%s Y=%s)", + errX, + errY, + ) + } + + p.X = int32(x) + p.Y = int32(y) + return nil +} diff --git a/render/point_test.go b/render/point_test.go new file mode 100644 index 0000000..d40c5da --- /dev/null +++ b/render/point_test.go @@ -0,0 +1,60 @@ +package render_test + +import ( + "strconv" + "testing" + + "git.kirsle.net/apps/doodle/lib/render" +) + +func TestPointInside(t *testing.T) { + type testCase struct { + rect render.Rect + p render.Point + shouldPass bool + } + tests := []testCase{ + testCase{ + rect: render.Rect{ + X: 0, + Y: 0, + W: 500, + H: 500, + }, + p: render.NewPoint(128, 256), + shouldPass: true, + }, + testCase{ + rect: render.Rect{ + X: 100, + Y: 80, + W: 40, + H: 60, + }, + p: render.NewPoint(128, 256), + shouldPass: false, + }, + testCase{ + // true values when debugging why Doodads weren't + // considered inside the viewport. + rect: render.Rect{ + X: 0, + Y: -232, + H: 874, + W: 490, + }, + p: render.NewPoint(509, 260), + shouldPass: false, + }, + } + + for _, test := range tests { + if test.p.Inside(test.rect) != test.shouldPass { + t.Errorf("Failed: %s inside %s should be %s", + test.p, + test.rect, + strconv.FormatBool(test.shouldPass), + ) + } + } +} diff --git a/render/rect_test.go b/render/rect_test.go new file mode 100644 index 0000000..f0528c1 --- /dev/null +++ b/render/rect_test.go @@ -0,0 +1,81 @@ +package render_test + +import ( + "strconv" + "testing" + + "git.kirsle.net/apps/doodle/lib/render" +) + +func TestIntersection(t *testing.T) { + newRect := func(x, y, w, h int) render.Rect { + return render.Rect{ + X: int32(x), + Y: int32(y), + W: int32(w), + H: int32(h), + } + } + + type TestCase struct { + A render.Rect + B render.Rect + Expect bool + } + var tests = []TestCase{ + { + A: newRect(0, 0, 1000, 1000), + B: newRect(200, 200, 100, 100), + Expect: true, + }, + { + A: newRect(200, 200, 100, 100), + B: newRect(0, 0, 1000, 1000), + Expect: true, + }, + { + A: newRect(0, 0, 100, 100), + B: newRect(100, 0, 100, 100), + Expect: true, + }, + { + A: newRect(0, 0, 99, 99), + B: newRect(100, 0, 99, 99), + Expect: false, + }, + { + // Real coords of a test doodad! + A: newRect(183, 256, 283, 356), + B: newRect(0, -232, 874, 490), + Expect: true, + }, + { + A: newRect(183, 256, 283, 356), + B: newRect(0, -240, 874, 490), + Expect: false, // XXX: must be true + }, + { + A: newRect(0, 30, 9, 62), + B: newRect(16, 0, 32, 64), + Expect: false, + }, + { + A: newRect(0, 30, 11, 62), + B: newRect(7, 4, 17, 28), + Expect: false, + }, + } + + for _, test := range tests { + actual := test.A.Intersects(test.B) + if actual != test.Expect { + t.Errorf( + "%s collision with %s: expected %s, got %s", + test.A, + test.B, + strconv.FormatBool(test.Expect), + strconv.FormatBool(actual), + ) + } + } +} diff --git a/render/sdl/canvas.go b/render/sdl/canvas.go new file mode 100644 index 0000000..0095585 --- /dev/null +++ b/render/sdl/canvas.go @@ -0,0 +1,57 @@ +// Package sdl provides an SDL2 renderer for Doodle. +package sdl + +import ( + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" +) + +// Clear the canvas and set this color. +func (r *Renderer) Clear(color render.Color) { + if color != r.lastColor { + r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha) + } + r.renderer.Clear() +} + +// DrawPoint puts a color at a pixel. +func (r *Renderer) DrawPoint(color render.Color, point render.Point) { + if color != r.lastColor { + r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha) + } + r.renderer.DrawPoint(point.X, point.Y) +} + +// DrawLine draws a line between two points. +func (r *Renderer) DrawLine(color render.Color, a, b render.Point) { + if color != r.lastColor { + r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha) + } + r.renderer.DrawLine(a.X, a.Y, b.X, b.Y) +} + +// DrawRect draws a rectangle. +func (r *Renderer) DrawRect(color render.Color, rect render.Rect) { + if color != r.lastColor { + r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha) + } + r.renderer.DrawRect(&sdl.Rect{ + X: rect.X, + Y: rect.Y, + W: rect.W, + H: rect.H, + }) +} + +// DrawBox draws a filled rectangle. +func (r *Renderer) DrawBox(color render.Color, rect render.Rect) { + if color != r.lastColor { + r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha) + } + r.renderer.FillRect(&sdl.Rect{ + X: rect.X, + Y: rect.Y, + W: rect.W, + H: rect.H, + }) +} diff --git a/render/sdl/events.go b/render/sdl/events.go new file mode 100644 index 0000000..152c906 --- /dev/null +++ b/render/sdl/events.go @@ -0,0 +1,171 @@ +package sdl + +import ( + "errors" + "fmt" + + "git.kirsle.net/apps/doodle/lib/events" + "github.com/veandco/go-sdl2/sdl" +) + +// Debug certain SDL events +var ( + DebugWindowEvents = false + DebugMouseEvents = false + DebugClickEvents = false + DebugKeyEvents = false +) + +// Poll for events. +func (r *Renderer) Poll() (*events.State, error) { + s := r.events + + // helper function to push keyboard key names on keyDown events only. + pushKey := func(name string, state uint8) { + if state == 1 { + s.KeyName.Push(name) + } + } + + for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { + switch t := event.(type) { + case *sdl.QuitEvent: + return s, errors.New("quit") + case *sdl.WindowEvent: + if DebugWindowEvents { + if t.Event == sdl.WINDOWEVENT_RESIZED { + fmt.Printf("[%d ms] tick:%d Window Resized to %dx%d", + t.Timestamp, + r.ticks, + t.Data1, + t.Data2, + ) + } + } + s.Resized.Push(true) + case *sdl.MouseMotionEvent: + if DebugMouseEvents { + fmt.Printf("[%d ms] tick:%d MouseMotion type:%d id:%d x:%d y:%d xrel:%d yrel:%d", + t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.XRel, t.YRel, + ) + } + + // Push the cursor position. + s.CursorX.Push(t.X) + s.CursorY.Push(t.Y) + s.Button1.Push(t.State == 1) + case *sdl.MouseButtonEvent: + if DebugClickEvents { + fmt.Printf("[%d ms] tick:%d MouseButton type:%d id:%d x:%d y:%d button:%d state:%d", + t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.Button, t.State, + ) + } + + // Push the cursor position. + s.CursorX.Push(t.X) + s.CursorY.Push(t.Y) + + // Is a mouse button pressed down? + checkDown := func(number uint8, target *events.BoolTick) bool { + if t.Button == number { + var eventName string + if t.State == 1 && target.Now == false { + eventName = "DOWN" + } else if t.State == 0 && target.Now == true { + eventName = "UP" + } + + if eventName != "" { + target.Push(eventName == "DOWN") + } + return true + } + return false + } + + if checkDown(1, s.Button1) || checkDown(3, s.Button2) || checkDown(2, s.Button3) { + // Return the event immediately. + return s, nil + } + case *sdl.MouseWheelEvent: + if DebugMouseEvents { + fmt.Printf("[%d ms] tick:%d MouseWheel type:%d id:%d x:%d y:%d", + t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, + ) + } + case *sdl.KeyboardEvent: + if DebugKeyEvents { + fmt.Printf("[%d ms] tick:%d Keyboard type:%d sym:%c modifiers:%d state:%d repeat:%d\n", + t.Timestamp, r.ticks, t.Type, t.Keysym.Sym, t.Keysym.Mod, t.State, t.Repeat, + ) + } + + switch t.Keysym.Scancode { + case sdl.SCANCODE_ESCAPE: + if t.Repeat == 1 { + continue + } + s.EscapeKey.Push(t.State == 1) + case sdl.SCANCODE_RETURN: + if t.Repeat == 1 { + continue + } + s.EnterKey.Push(t.State == 1) + case sdl.SCANCODE_F1: + pushKey("F1", t.State) + case sdl.SCANCODE_F2: + pushKey("F2", t.State) + case sdl.SCANCODE_F3: + pushKey("F3", t.State) + case sdl.SCANCODE_F4: + pushKey("F4", t.State) + case sdl.SCANCODE_F5: + pushKey("F5", t.State) + case sdl.SCANCODE_F6: + pushKey("F6", t.State) + case sdl.SCANCODE_F7: + pushKey("F7", t.State) + case sdl.SCANCODE_F8: + pushKey("F8", t.State) + case sdl.SCANCODE_F9: + pushKey("F9", t.State) + case sdl.SCANCODE_F10: + pushKey("F10", t.State) + case sdl.SCANCODE_F11: + pushKey("F11", t.State) + case sdl.SCANCODE_F12: + pushKey("F12", t.State) + case sdl.SCANCODE_UP: + s.Up.Push(t.State == 1) + case sdl.SCANCODE_LEFT: + s.Left.Push(t.State == 1) + case sdl.SCANCODE_RIGHT: + s.Right.Push(t.State == 1) + case sdl.SCANCODE_DOWN: + s.Down.Push(t.State == 1) + case sdl.SCANCODE_LSHIFT: + case sdl.SCANCODE_RSHIFT: + s.ShiftActive.Push(t.State == 1) + case sdl.SCANCODE_LALT: + case sdl.SCANCODE_RALT: + continue + case sdl.SCANCODE_LCTRL: + s.ControlActive.Push(t.State == 1) + case sdl.SCANCODE_RCTRL: + s.ControlActive.Push(t.State == 1) + case sdl.SCANCODE_BACKSPACE: + // Make it a key event with "\b" as the sequence. + if t.State == 1 || t.Repeat == 1 { + s.KeyName.Push(`\b`) + } + default: + // Push the string value of the key. + if t.State == 1 { + s.KeyName.Push(string(t.Keysym.Sym)) + } + } + } + } + + return s, nil +} diff --git a/render/sdl/sdl.go b/render/sdl/sdl.go new file mode 100644 index 0000000..c54767f --- /dev/null +++ b/render/sdl/sdl.go @@ -0,0 +1,120 @@ +// Package sdl provides an SDL2 renderer for Doodle. +package sdl + +import ( + "fmt" + "time" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" + "github.com/veandco/go-sdl2/ttf" +) + +// Renderer manages the SDL state. +type Renderer struct { + // Configurable fields. + title string + width int32 + height int32 + startTime time.Time + + // Private fields. + events *events.State + window *sdl.Window + renderer *sdl.Renderer + running bool + ticks uint64 + textures map[string]*Texture // cached textures + + // Optimizations to minimize SDL calls. + lastColor render.Color +} + +// New creates the SDL renderer. +func New(title string, width, height int) *Renderer { + return &Renderer{ + events: events.New(), + title: title, + width: int32(width), + height: int32(height), + textures: map[string]*Texture{}, + } +} + +// Teardown tasks when exiting the program. +func (r *Renderer) Teardown() { + r.renderer.Destroy() + r.window.Destroy() + sdl.Quit() +} + +// Setup the renderer. +func (r *Renderer) Setup() error { + // Initialize SDL. + if err := sdl.Init(sdl.INIT_EVERYTHING); err != nil { + return fmt.Errorf("sdl.Init: %s", err) + } + + // Initialize SDL_TTF. + if err := ttf.Init(); err != nil { + return fmt.Errorf("ttf.Init: %s", err) + } + + // Create our window. + window, err := sdl.CreateWindow( + r.title, + sdl.WINDOWPOS_CENTERED, + sdl.WINDOWPOS_CENTERED, + r.width, + r.height, + sdl.WINDOW_SHOWN|sdl.WINDOW_RESIZABLE, + ) + if err != nil { + return err + } + r.window = window + + // Blank out the window in white. + renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED) + if err != nil { + panic(err) + } + renderer.SetDrawBlendMode(sdl.BLENDMODE_BLEND) + r.renderer = renderer + + return nil +} + +// SetTitle sets the SDL window title. +func (r *Renderer) SetTitle(title string) { + r.title = title + r.window.SetTitle(title) +} + +// GetTicks gets SDL's current tick count. +func (r *Renderer) GetTicks() uint32 { + return sdl.GetTicks() +} + +// WindowSize returns the SDL window size. +func (r *Renderer) WindowSize() (int, int) { + w, h := r.window.GetSize() + return int(w), int(h) +} + +// Present the current frame. +func (r *Renderer) Present() error { + r.renderer.Present() + return nil +} + +// Delay using sdl.Delay +func (r *Renderer) Delay(time uint32) { + sdl.Delay(time) +} + +// Loop is the main loop. +func (r *Renderer) Loop() error { + return nil +} diff --git a/render/sdl/text.go b/render/sdl/text.go new file mode 100644 index 0000000..efe59fd --- /dev/null +++ b/render/sdl/text.go @@ -0,0 +1,199 @@ +package sdl + +import ( + "fmt" + "strings" + "sync" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" + "github.com/veandco/go-sdl2/ttf" +) + +// TODO: font filenames +var defaultFontFilename = "DejaVuSans.ttf" + +// Font holds cached SDL_TTF structures for loaded fonts. They are created +// automatically when fonts are either preinstalled (InstallFont) or loaded for +// the first time as demanded by the DrawText method. +type Font struct { + Filename string + data []byte // raw binary data of font + ttf *ttf.Font +} + +var ( + fonts = map[string]*ttf.Font{} // keys like "DejaVuSans@14" by font size + installedFont = map[string][]byte{} // installed font files' binary handles + fontsMu sync.RWMutex +) + +// InstallFont preloads the font cache using TTF binary data in memory. +func InstallFont(filename string, binary []byte) { + fontsMu.Lock() + installedFont[filename] = binary + fontsMu.Unlock() +} + +// LoadFont loads and caches the font at a given size. +func LoadFont(filename string, size int) (*ttf.Font, error) { + if filename == "" { + filename = defaultFontFilename + } + + // Cached font available? + keyName := fmt.Sprintf("%s@%d", filename, size) + if font, ok := fonts[keyName]; ok { + return font, nil + } + + // Do we have this font in memory? + var ( + font *ttf.Font + err error + ) + + if binary, ok := installedFont[filename]; ok { + var RWops *sdl.RWops + RWops, err = sdl.RWFromMem(binary) + if err != nil { + return nil, fmt.Errorf("LoadFont(%s): RWFromMem: %s", filename, err) + } + + font, err = ttf.OpenFontRW(RWops, 0, size) + } else { + font, err = ttf.OpenFont(filename, size) + } + + // Error opening the font? + if err != nil { + return nil, fmt.Errorf("LoadFont(%s): %s", filename, err) + } + + // Cache this font name and size. + fonts[keyName] = font + + return font, nil +} + +// Keysym returns the current key pressed, taking into account the Shift +// key modifier. +func (r *Renderer) Keysym(ev *events.State) string { + if key := ev.KeyName.Read(); key != "" { + if ev.ShiftActive.Pressed() { + if symbol, ok := shiftMap[key]; ok { + return symbol + } + return strings.ToUpper(key) + } + } + return "" +} + +// ComputeTextRect computes and returns a Rect for how large the text would +// appear if rendered. +func (r *Renderer) ComputeTextRect(text render.Text) (render.Rect, error) { + var ( + rect render.Rect + font *ttf.Font + surface *sdl.Surface + color = ColorToSDL(text.Color) + err error + ) + + if font, err = LoadFont(text.FontFilename, text.Size); err != nil { + return rect, err + } + + if surface, err = font.RenderUTF8Blended(text.Text, color); err != nil { + return rect, err + } + defer surface.Free() + + rect.W = surface.W + rect.H = surface.H + return rect, err +} + +// DrawText draws text on the canvas. +func (r *Renderer) DrawText(text render.Text, point render.Point) error { + var ( + font *ttf.Font + surface *sdl.Surface + tex *sdl.Texture + err error + ) + + if font, err = LoadFont(text.FontFilename, text.Size); err != nil { + return err + } + + write := func(dx, dy int32, color sdl.Color) { + if surface, err = font.RenderUTF8Blended(text.Text, color); err != nil { + return + } + defer surface.Free() + + if tex, err = r.renderer.CreateTextureFromSurface(surface); err != nil { + return + } + defer tex.Destroy() + + tmp := &sdl.Rect{ + X: point.X + dx, + Y: point.Y + dy, + W: surface.W, + H: surface.H, + } + r.renderer.Copy(tex, nil, tmp) + } + + // Does the text have a stroke around it? + if text.Stroke != render.Invisible { + color := ColorToSDL(text.Stroke) + write(-1, -1, color) + write(-1, 0, color) + write(-1, 1, color) + write(1, -1, color) + write(1, 0, color) + write(1, 1, color) + write(0, -1, color) + write(0, 1, color) + } + + // Does it have a drop shadow? + if text.Shadow != render.Invisible { + write(1, 1, ColorToSDL(text.Shadow)) + } + + // Draw the text itself. + write(0, 0, ColorToSDL(text.Color)) + + return err +} + +// shiftMap maps keys to their Shift versions. +var shiftMap = map[string]string{ + "`": "~", + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "[": "{", + "]": "}", + `\`: "|", + ";": ":", + `'`: `"`, + ",": "<", + ".": ">", + "/": "?", +} diff --git a/render/sdl/texture.go b/render/sdl/texture.go new file mode 100644 index 0000000..27b0cc7 --- /dev/null +++ b/render/sdl/texture.go @@ -0,0 +1,84 @@ +package sdl + +import ( + "bytes" + "fmt" + "image" + + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" + "golang.org/x/image/bmp" +) + +// Copy a texture into the renderer. +func (r *Renderer) Copy(t render.Texturer, src, dst render.Rect) { + if tex, ok := t.(*Texture); ok { + var ( + a = RectToSDL(src) + b = RectToSDL(dst) + ) + r.renderer.Copy(tex.tex, &a, &b) + } +} + +// Texture can hold on to SDL textures for caching and optimization. +type Texture struct { + tex *sdl.Texture + width int32 + height int32 +} + +// StoreTexture caches an SDL texture from a bitmap. +func (r *Renderer) StoreTexture(name string, img image.Image) (render.Texturer, error) { + var ( + fh = bytes.NewBuffer([]byte{}) + ) + + err := bmp.Encode(fh, img) + if err != nil { + return nil, fmt.Errorf("NewTexture: bmp.Encode: %s", err) + } + + // Create an SDL RWOps from the bitmap data in memory. + sdlRW, err := sdl.RWFromMem(fh.Bytes()) + if err != nil { + return nil, fmt.Errorf("NewTexture: sdl.RWFromMem: %s", err) + } + + surface, err := sdl.LoadBMPRW(sdlRW, true) + if err != nil { + return nil, fmt.Errorf("NewTexture: sdl.LoadBMPRW: %s", err) + } + defer surface.Free() + + // TODO: chroma key color hardcoded to white here + key := sdl.MapRGB(surface.Format, 255, 255, 255) + surface.SetColorKey(true, key) + + texture, err := r.renderer.CreateTextureFromSurface(surface) + if err != nil { + return nil, fmt.Errorf("NewBitmap: create texture: %s", err) + } + + tex := &Texture{ + width: surface.W, + height: surface.H, + tex: texture, + } + r.textures[name] = tex + + return tex, nil +} + +// Size returns the dimensions of the texture. +func (t *Texture) Size() render.Rect { + return render.NewRect(t.width, t.height) +} + +// LoadTexture initializes a texture from a bitmap image. +func (r *Renderer) LoadTexture(name string) (render.Texturer, error) { + if tex, ok := r.textures[name]; ok { + return tex, nil + } + return nil, fmt.Errorf("LoadTexture(%s): not found in texture cache", name) +} diff --git a/render/sdl/utils.go b/render/sdl/utils.go new file mode 100644 index 0000000..3c7f260 --- /dev/null +++ b/render/sdl/utils.go @@ -0,0 +1,26 @@ +package sdl + +import ( + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" +) + +// ColorToSDL converts Doodle's Color type to an sdl.Color. +func ColorToSDL(c render.Color) sdl.Color { + return sdl.Color{ + R: c.Red, + G: c.Green, + B: c.Blue, + A: c.Alpha, + } +} + +// RectToSDL converts Doodle's Rect type to an sdl.Rect. +func RectToSDL(r render.Rect) sdl.Rect { + return sdl.Rect{ + X: r.X, + Y: r.Y, + W: r.W, + H: r.H, + } +} diff --git a/render/shapes.go b/render/shapes.go new file mode 100644 index 0000000..fb873cb --- /dev/null +++ b/render/shapes.go @@ -0,0 +1,105 @@ +package render + +import ( + "math" +) + +// IterLine is a generator that returns the X,Y coordinates to draw a line. +// https://en.wikipedia.org/wiki/Digital_differential_analyzer_(graphics_algorithm) +func IterLine(p1 Point, p2 Point) chan Point { + var ( + x1 = p1.X + y1 = p1.Y + x2 = p2.X + y2 = p2.Y + ) + generator := make(chan Point) + + go func() { + var ( + dx = float64(x2 - x1) + dy = float64(y2 - y1) + ) + var step float64 + if math.Abs(dx) >= math.Abs(dy) { + step = math.Abs(dx) + } else { + step = math.Abs(dy) + } + + dx = dx / step + dy = dy / step + x := float64(x1) + y := float64(y1) + for i := 0; i <= int(step); i++ { + generator <- Point{ + X: int32(x), + Y: int32(y), + } + x += dx + y += dy + } + + close(generator) + }() + + return generator +} + +// IterRect loops through all the points forming a rectangle between the +// top-left point and the bottom-right point. +func IterRect(p1, p2 Point) chan Point { + generator := make(chan Point) + + go func() { + var ( + TopLeft = p1 + BottomRight = p2 + TopRight = Point{ + X: BottomRight.X, + Y: TopLeft.Y, + } + BottomLeft = Point{ + X: TopLeft.X, + Y: BottomRight.Y, + } + dedupe = map[Point]interface{}{} + ) + + // Trace all four edges and yield it. + var edges = []struct { + A Point + B Point + }{ + {TopLeft, TopRight}, + {TopLeft, BottomLeft}, + {BottomLeft, BottomRight}, + {TopRight, BottomRight}, + } + for _, edge := range edges { + for pt := range IterLine(edge.A, edge.B) { + if _, ok := dedupe[pt]; !ok { + generator <- pt + dedupe[pt] = nil + } + } + } + + close(generator) + }() + + return generator +} + +// IterEllipse iterates an Ellipse using two Points as the top-left and +// bottom-right corners of a rectangle that encompasses the ellipse. +func IterEllipse(A, B Point) chan Point { + var ( + width = AbsInt32(B.X - A.X) + height = AbsInt32(B.Y - A.Y) + radius = NewPoint(width/2, height/2) + center = NewPoint(AbsInt32(B.X-radius.X), AbsInt32(B.Y-radius.Y)) + ) + + return MidpointEllipse(center, radius) +}