From cddc41465fd076a8c467fc6a2631662feb2f527e Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 9 Apr 2019 17:35:44 -0700 Subject: [PATCH] Code Layout Refactor * All private Doodle source code into the pkg/ folder. * Potentially public code into the lib/ folder. * Centralize the logger into a subpackage. --- color.go | 192 ++++++++++++++++++++++++++++++++++++++++++ functions.go | 69 +++++++++++++++ interface.go | 221 +++++++++++++++++++++++++++++++++++++++++++++++++ log.go | 14 ++++ point.go | 103 +++++++++++++++++++++++ point_test.go | 60 ++++++++++++++ rect_test.go | 71 ++++++++++++++++ sdl/canvas.go | 57 +++++++++++++ sdl/events.go | 131 +++++++++++++++++++++++++++++ sdl/fps.go | 20 +++++ sdl/log.go | 17 ++++ sdl/sdl.go | 115 +++++++++++++++++++++++++ sdl/text.go | 158 +++++++++++++++++++++++++++++++++++ sdl/texture.go | 55 ++++++++++++ sdl/utils.go | 26 ++++++ 15 files changed, 1309 insertions(+) create mode 100644 color.go create mode 100644 functions.go create mode 100644 interface.go create mode 100644 log.go create mode 100644 point.go create mode 100644 point_test.go create mode 100644 rect_test.go create mode 100644 sdl/canvas.go create mode 100644 sdl/events.go create mode 100644 sdl/fps.go create mode 100644 sdl/log.go create mode 100644 sdl/sdl.go create mode 100644 sdl/text.go create mode 100644 sdl/texture.go create mode 100644 sdl/utils.go diff --git a/color.go b/color.go new file mode 100644 index 0000000..c16cda2 --- /dev/null +++ b/color.go @@ -0,0 +1,192 @@ +package render + +import ( + "encoding/json" + "errors" + "fmt" + "image/color" + "regexp" + "strconv" +) + +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, + ) +} + +// 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 +} + +// Add a relative color value to the color. +func (c Color) Add(r, g, b, a int32) Color { + var ( + R = int32(c.Red) + r + G = int32(c.Green) + g + B = int32(c.Blue) + b + A = int32(c.Alpha) + a + ) + + cap8 := func(v int32) 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), + } +} + +// Lighten a color value. +func (c Color) Lighten(v int32) Color { + return c.Add(v, v, v, 0) +} + +// Darken a color value. +func (c Color) Darken(v int32) Color { + return c.Add(-v, -v, -v, 0) +} diff --git a/functions.go b/functions.go new file mode 100644 index 0000000..b78a254 --- /dev/null +++ b/functions.go @@ -0,0 +1,69 @@ +package render + +// 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 + } +} diff --git a/interface.go b/interface.go new file mode 100644 index 0000000..7d07503 --- /dev/null +++ b/interface.go @@ -0,0 +1,221 @@ +package render + +import ( + "fmt" + "math" + + "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) + DrawPoint(Color, Point) + DrawLine(Color, Point, Point) + DrawRect(Color, Rect) + DrawBox(Color, Rect) + DrawText(Text, Point) error + ComputeTextRect(Text) (Rect, error) + + // Texture caching. + NewBitmap(filename 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, + } +} + +// 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) + DarkYellow = RGBA(153, 153, 0, 255) + Magenta = RGBA(255, 0, 255, 255) + Purple = RGBA(153, 0, 153, 255) + Pink = RGBA(255, 153, 255, 255) +) + +// 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(x1, y1, x2, y2 int32) chan Point { + 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 +} + +// IterLine2 works with two Points rather than four coordinates. +func IterLine2(p1 Point, p2 Point) chan Point { + return IterLine(p1.X, p1.Y, p2.X, p2.Y) +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..0891650 --- /dev/null +++ b/log.go @@ -0,0 +1,14 @@ +package render + +import "github.com/kirsle/golog" + +var log *golog.Logger + +func init() { + log = golog.GetLogger("doodle") + log.Configure(&golog.Config{ + Level: golog.DebugLevel, + Theme: golog.DarkTheme, + Colors: golog.ExtendedColor, + }) +} diff --git a/point.go b/point.go new file mode 100644 index 0000000..272238c --- /dev/null +++ b/point.go @@ -0,0 +1,103 @@ +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 +} + +// 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/point_test.go b/point_test.go new file mode 100644 index 0000000..d40c5da --- /dev/null +++ b/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/rect_test.go b/rect_test.go new file mode 100644 index 0000000..c6d8bb4 --- /dev/null +++ b/rect_test.go @@ -0,0 +1,71 @@ +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 + }, + } + + 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/sdl/canvas.go b/sdl/canvas.go new file mode 100644 index 0000000..0095585 --- /dev/null +++ b/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/sdl/events.go b/sdl/events.go new file mode 100644 index 0000000..f84b3c3 --- /dev/null +++ b/sdl/events.go @@ -0,0 +1,131 @@ +package sdl + +import ( + "errors" + + "git.kirsle.net/apps/doodle/lib/events" + "github.com/veandco/go-sdl2/sdl" +) + +// Poll for events. +func (r *Renderer) Poll() (*events.State, error) { + s := r.events + 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 { + log.Debug("[%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 { + log.Debug("[%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 { + log.Debug("[%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) { + // Return the event immediately. + return s, nil + } + case *sdl.MouseWheelEvent: + if DebugMouseEvents { + log.Debug("[%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 { + log.Debug("[%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_F12: + s.ScreenshotKey.Push(t.State == 1) + 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) + continue + case sdl.SCANCODE_LALT: + case sdl.SCANCODE_RALT: + case sdl.SCANCODE_LCTRL: + case sdl.SCANCODE_RCTRL: + continue + 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/sdl/fps.go b/sdl/fps.go new file mode 100644 index 0000000..31c1c17 --- /dev/null +++ b/sdl/fps.go @@ -0,0 +1,20 @@ +package sdl + +import "git.kirsle.net/apps/doodle/pkg/level" + +// Frames to cache for FPS calculation. +const ( + maxSamples = 100 + TargetFPS = 1000 / 60 +) + +var ( + fpsCurrentTicks uint32 // current time we get sdl.GetTicks() + fpsLastTime uint32 // last time we printed the fpsCurrentTicks + fpsCurrent int + fpsFrames int + fpsSkipped uint32 + fpsInterval uint32 = 1000 +) + +var pixelHistory []level.Pixel diff --git a/sdl/log.go b/sdl/log.go new file mode 100644 index 0000000..00c5fef --- /dev/null +++ b/sdl/log.go @@ -0,0 +1,17 @@ +package sdl + +import "github.com/kirsle/golog" + +var log *golog.Logger + +// Verbose debug logging. +var ( + DebugMouseEvents = false + DebugClickEvents = false + DebugKeyEvents = false + DebugWindowEvents = false +) + +func init() { + log = golog.GetLogger("doodle") +} diff --git a/sdl/sdl.go b/sdl/sdl.go new file mode 100644 index 0000000..538c3a1 --- /dev/null +++ b/sdl/sdl.go @@ -0,0 +1,115 @@ +// Package sdl provides an SDL2 renderer for Doodle. +package sdl + +import ( + "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 + + // 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), + } +} + +// 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. + log.Info("Initializing SDL") + if err := sdl.Init(sdl.INIT_EVERYTHING); err != nil { + return err + } + + // Initialize SDL_TTF. + log.Info("Initializing SDL_TTF") + if err := ttf.Init(); err != nil { + return err + } + + // Create our window. + log.Info("Creating the Main 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. + log.Info("Creating the SDL Renderer") + renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED) + if err != nil { + panic(err) + } + renderer.SetDrawBlendMode(sdl.BLENDMODE_BLEND) + r.renderer = renderer + + return nil +} + +// 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/sdl/text.go b/sdl/text.go new file mode 100644 index 0000000..caac55d --- /dev/null +++ b/sdl/text.go @@ -0,0 +1,158 @@ +package sdl + +import ( + "fmt" + "strings" + + "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 = "./fonts/DejaVuSans.ttf" + +var fonts = map[string]*ttf.Font{} + +// 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 + } + + font, err := ttf.OpenFont(filename, size) + if err != nil { + return nil, err + } + 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/sdl/texture.go b/sdl/texture.go new file mode 100644 index 0000000..b7843cc --- /dev/null +++ b/sdl/texture.go @@ -0,0 +1,55 @@ +package sdl + +import ( + "fmt" + + "git.kirsle.net/apps/doodle/lib/render" + "github.com/veandco/go-sdl2/sdl" +) + +// 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 +} + +// Size returns the dimensions of the texture. +func (t *Texture) Size() render.Rect { + return render.NewRect(t.width, t.height) +} + +// NewBitmap initializes a texture from a bitmap image. +func (r *Renderer) NewBitmap(filename string) (render.Texturer, error) { + surface, err := sdl.LoadBMP(filename) + if err != nil { + return nil, fmt.Errorf("NewBitmap: LoadBMP: %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) + + tex, err := r.renderer.CreateTextureFromSurface(surface) + if err != nil { + return nil, fmt.Errorf("NewBitmap: create texture: %s", err) + } + + return &Texture{ + width: surface.W, + height: surface.H, + tex: tex, + }, nil +} diff --git a/sdl/utils.go b/sdl/utils.go new file mode 100644 index 0000000..3c7f260 --- /dev/null +++ b/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, + } +}