diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a4dd07e --- /dev/null +++ b/TODO.md @@ -0,0 +1,78 @@ +# TODO + +## Alpha Launch Minimum Checklist + +- [ ] Open Source Licenses +- [ ] Doodad Scripts: an "end level" function for a level goalpost. + + +**Blocker Bugs:** + +- [ ] Sometimes the red Azulians don't interact with other doodads + properly, but sometimes they do. (i.e. they phase thru doors, don't + interact with buttons or keys). + +**UI Cleanup:** + +- Doodads Palette: + - [ ] Hide some doodads like the player character. + - [ ] Pagination or scrolling UI for long lists of doodads. + +**Nice to haves:** + +## Release Launch Checklist + +**Features:** + +- [ ] Single-player "campaign mode" of built-in levels. + - campaign.json file format configuring the level order +- [ ] Level Editor Improvements + - [ ] Undo/Redo Function + - [ ] Lines and Boxes + - [ ] Eraser Tool + - [ ] Brush size and/or shape +- [ ] Doodad CLI Tool Features + - [ ] `doodad show` to display information about a level or doodad. + - [ ] `doodad init` or some such to generate a default JS script. + - [ ] Options to toggle various states (hidden, hasInventory?) + +**Shareware Version:** + +- [x] Can't draw or edit doodads. +- [ ] Can only create Bounded maps, not infinite ones. +- [ ] Can play custom maps but only ones using built-in doodads. +- [ ] Can not place custom doodads in maps. + +**Built-in Doodads:** + +- [x] Buttons + - [x] Press Button + - [x] Sticky Button +- [ ] Switches +- [ ] Doors + - [x] Locked Doors and Keys + - [x] Electric Doors + - [ ] Trapdoors (1 of 4) + +## Doodad Ideas + +In addition to those listed above: + +- [ ] Crumbly floor: Tomb Raider inspired cracked stone floor that + crumbles under the player a moment after being touched. +- [ ] Firepit: decorative, painful +- [ ] Gravity Boots: flip player's gravity upside down. +- [ ] Warp Doors that lead to other linked maps. + - For campaign levels only. If used in a normal player level, acts + as a level goal and ends the level. + - Doodads "Warp Door A" through "Warp Door D" + - The campaign.json would link levels together. + +## New Ideas + +- [ ] New Doodad struct fields: + - [ ] `Hidden bool`: skip showing this doodad in the palette UI. + - [ ] `HasInventory bool`: for player characters and maybe thieves. This way + keys only get picked up by player characters and not "any doodad that + touches them" + - [ ] `` diff --git a/lib/events/events.go b/lib/events/events.go index c3957ec..1e8836e 100644 --- a/lib/events/events.go +++ b/lib/events/events.go @@ -12,14 +12,15 @@ type State struct { Button2 *BoolTick // right Button3 *BoolTick // middle - EscapeKey *BoolTick - EnterKey *BoolTick - ShiftActive *BoolTick - KeyName *StringTick - Up *BoolTick - Left *BoolTick - Right *BoolTick - Down *BoolTick + EscapeKey *BoolTick + EnterKey *BoolTick + ShiftActive *BoolTick + ControlActive *BoolTick + KeyName *StringTick + Up *BoolTick + Left *BoolTick + Right *BoolTick + Down *BoolTick // Cursor positions. CursorX *Int32Tick @@ -32,20 +33,21 @@ type State struct { // New creates a new event state manager. func New() *State { return &State{ - Button1: &BoolTick{}, - Button2: &BoolTick{}, - Button3: &BoolTick{}, - EscapeKey: &BoolTick{}, - EnterKey: &BoolTick{}, - ShiftActive: &BoolTick{}, - KeyName: &StringTick{}, - Up: &BoolTick{}, - Left: &BoolTick{}, - Right: &BoolTick{}, - Down: &BoolTick{}, - CursorX: &Int32Tick{}, - CursorY: &Int32Tick{}, - Resized: &BoolTick{}, + Button1: &BoolTick{}, + Button2: &BoolTick{}, + Button3: &BoolTick{}, + EscapeKey: &BoolTick{}, + EnterKey: &BoolTick{}, + ShiftActive: &BoolTick{}, + ControlActive: &BoolTick{}, + KeyName: &StringTick{}, + Up: &BoolTick{}, + Left: &BoolTick{}, + Right: &BoolTick{}, + Down: &BoolTick{}, + CursorX: &Int32Tick{}, + CursorY: &Int32Tick{}, + Resized: &BoolTick{}, } } diff --git a/lib/render/sdl/events.go b/lib/render/sdl/events.go index 6c958da..152c906 100644 --- a/lib/render/sdl/events.go +++ b/lib/render/sdl/events.go @@ -146,12 +146,13 @@ func (r *Renderer) Poll() (*events.State, error) { 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_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 { diff --git a/pkg/balance/debug.go b/pkg/balance/debug.go index 4365efb..a33c672 100644 --- a/pkg/balance/debug.go +++ b/pkg/balance/debug.go @@ -31,6 +31,10 @@ var ( DebugCanvasBorder = render.Invisible DebugCanvasLabel = false // Tag the canvas with a label. + // Set to a color other than Invisible to force the uix.Canvas to color ALL + // Stroke pixels in this color. + DebugCanvasStrokeColor = render.Invisible + // Pretty-print JSON files when writing. JSONIndent = true ) diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index ec9e2d2..dbc2f8a 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -24,6 +24,9 @@ var ( // Default size for a new Doodad. DoodadSize = 100 + + // Size of Undo/Redo history for map editor. + UndoHistory = 20 ) // Edit Mode Values diff --git a/pkg/drawtool/history.go b/pkg/drawtool/history.go new file mode 100644 index 0000000..9aa39bd --- /dev/null +++ b/pkg/drawtool/history.go @@ -0,0 +1,131 @@ +package drawtool + +// History manages a history of Strokes added to a drawing. +type History struct { + limit int + head *HistoryElement // oldest history element, top of linked list + tail *HistoryElement // newest element added to history +} + +// HistoryElement is a doubly linked list of stroke history. +type HistoryElement struct { + stroke *Stroke + next *HistoryElement + previous *HistoryElement +} + +// NewHistory initializes a History list. +func NewHistory(limit int) *History { + return &History{ + limit: limit, + } +} + +// Reset clears the history. +func (h *History) Reset() { + h.head = nil + h.tail = nil +} + +// Size returns the current size of the history list. +func (h *History) Size() int { + var ( + size int + node = h.head + ) + + for node != nil { + size++ + node = node.next + } + + return size +} + +// Latest returns the tail of the history (the most recent stroke). If you had +// recently called Undo, the latest stroke may still have a 'next' stroke. +// Returns nil if there was no stroke in history. +func (h *History) Latest() *Stroke { + if h.tail == nil { + return nil + } + return h.tail.stroke +} + +// Oldest returns the head of the history (the earliest stroke added). If the +// history size limit had been reached, the oldest stroke will creep along +// forward and not necessarily be the FIRST EVER stroke added. +func (h *History) Oldest() *Stroke { + if h.head == nil { + return nil + } + return h.head.stroke +} + +// AddStroke adds a stroke to the history, becoming the new tail at the end +// of the history data. +func (h *History) AddStroke(s *Stroke) { + var ( + elem = &HistoryElement{ + stroke: s, + } + tail = h.tail + ) + + // Make the current tail point to this one. + if tail != nil { + tail.next = elem + elem.previous = tail + } + + // First stroke of the history? Make it the head of the linked list. + if h.head == nil { + h.head = elem + } + + h.tail = elem + + // Have we reached the history storage limit? + var size = h.Size() + if size > h.limit { + var node = h.tail + for i := 0; i < h.limit-1; i++ { + if node.previous == nil { + break + } + node = node.previous + } + h.head = node + h.head.previous = nil + } +} + +// Undo steps back a step in the history. This sets the current tail to point +// to the "tail - 1" element, but doesn't change the link of that element to +// its future value yet; so that you can Redo it. But if you add a new stroke +// from this state, it will overwrite the tail.next and invalidate the old +// history that came after, starting a new branch of history from that point on. +// +// Returns false if the undo failed (no earlier node to move to). +func (h *History) Undo() bool { + if h.tail == nil { + return false + } + + // if h.tail.previous == nil { + // return false + // } + + h.tail = h.tail.previous + return true +} + +// Redo advances forwards after a recent Undo. Note that if you added new strokes +// after an Undo, the new tail has no next node to move to and Redo returns false. +func (h *History) Redo() bool { + if h.tail == nil || h.tail.next == nil { + return false + } + h.tail = h.tail.next + return true +} diff --git a/pkg/drawtool/history_test.go b/pkg/drawtool/history_test.go new file mode 100644 index 0000000..7bcf742 --- /dev/null +++ b/pkg/drawtool/history_test.go @@ -0,0 +1,132 @@ +package drawtool + +import ( + "testing" + + "git.kirsle.net/apps/doodle/lib/render" +) + +func TestHistory(t *testing.T) { + // Test assertion helpers. + shouldBool := func(note string, expect, actual bool) { + if actual != expect { + t.Errorf( + "Unexpected boolean result (%s)\n"+ + "Expected: %+v\n"+ + " Got: %+v", + note, + expect, + actual, + ) + } + } + shouldInt := func(note string, expect, actual int) { + if actual != expect { + t.Errorf( + "Unexpected integer result (%s)\n"+ + "Expected: %+v\n"+ + " Got: %+v", + note, + expect, + actual, + ) + } + } + shouldPoint := func(note string, expect render.Point, actual *Stroke) { + if actual == nil { + t.Errorf("Missing history stroke for shouldPoint(%s)", note) + return + } + if actual.PointA != expect { + t.Errorf( + "Unexpected point result (%s)\n"+ + "Expected: %+v\n"+ + " Got: %+v", + note, + expect, + actual.PointA, + ) + } + } + + var H = NewHistory(10) + + // Add and remove and re-add the first element. + H.AddStroke(&Stroke{ + PointA: render.NewPoint(999, 999), + }) + shouldInt("first element", 1, H.Size()) + shouldBool("can undo first element", true, H.Undo()) + shouldBool("latest should be null", true, H.Latest() == nil) + + H = NewHistory(10) + + shouldBool("can't Undo with fresh history", false, H.Undo()) + shouldInt("size should be zero", 0, H.Size()) + + H.AddStroke(&Stroke{ + PointA: render.NewPoint(1, 1), + }) + + shouldInt("after first stroke", 1, H.Size()) + shouldPoint("head is the newest point", render.NewPoint(1, 1), H.Latest()) + + H.AddStroke(&Stroke{ + PointA: render.NewPoint(2, 2), + }) + + shouldInt("after second stroke", 2, H.Size()) + shouldPoint("head is the newest point", render.NewPoint(2, 2), H.Latest()) + + // Undo. + shouldBool("undo second stroke", true, H.Undo()) + shouldInt("after undo the future stroke is still part of the size", 2, H.Size()) + shouldPoint("after undo, the newest point", render.NewPoint(1, 1), H.Latest()) + + // Redo. + shouldBool("redo second stroke", true, H.Redo()) + shouldInt("after redo second stroke, size is still the same", 2, H.Size()) + shouldPoint("after redo, the newest point", render.NewPoint(2, 2), H.Latest()) + + // Another redo must fail. + shouldBool("redo when there is nothing to redo", false, H.Redo()) + + // Add a few more points. + for i := 3; i <= 6; i++ { + H.AddStroke(&Stroke{ + PointA: render.NewPoint(int32(i), int32(i)), + }) + } + shouldInt("after adding more strokes", 6, H.Size()) + shouldPoint("last point added", render.NewPoint(6, 6), H.Latest()) + + // Undo a few times. + shouldBool("undo^1", true, H.Undo()) + shouldBool("undo^2", true, H.Undo()) + shouldBool("undo^3", true, H.Undo()) + shouldInt("after a few undos, the size still contains future history", 6, H.Size()) + + // A new stroke invalidates the future history. + H.AddStroke(&Stroke{ + PointA: render.NewPoint(7, 7), + }) + shouldInt("after new history, size is recapped to tail", 4, H.Size()) + shouldBool("can't Redo after new point added", false, H.Redo()) + + // Overflow past our history size to test rollover. + for i := 8; i <= 16; i++ { + H.AddStroke(&Stroke{ + PointA: render.NewPoint(int32(i), int32(i)), + }) + } + shouldInt("after tons of new history, size is capped out", 10, H.Size()) + shouldPoint("after overflow, latest point", render.NewPoint(16, 16), H.Latest()) + shouldPoint("after overflow, first point", render.NewPoint(7, 7), H.Oldest()) + + // Undo back to beginning. + for i := 0; i < H.Size(); i++ { + shouldBool("bulk undo to beginning", true, H.Undo()) + } + shouldBool("after bulk undo, tail", true, H.Latest() == nil) + shouldBool("can't undo further", false, H.Undo()) +} diff --git a/pkg/drawtool/shapes.go b/pkg/drawtool/shapes.go new file mode 100644 index 0000000..c11cc6d --- /dev/null +++ b/pkg/drawtool/shapes.go @@ -0,0 +1,11 @@ +package drawtool + +// Shape of a stroke line. +type Shape int + +// Shape values. +const ( + Freehand Shape = iota + Line + Rectangle +) diff --git a/pkg/drawtool/stroke.go b/pkg/drawtool/stroke.go new file mode 100644 index 0000000..15a3960 --- /dev/null +++ b/pkg/drawtool/stroke.go @@ -0,0 +1,77 @@ +package drawtool + +import "git.kirsle.net/apps/doodle/lib/render" + +/* +Stroke holds temporary pixel data with a shape and color. + +It is used for myriad purposes: + +- As a staging area for drawing new pixels to the drawing without committing + them until completed. +- As a unit of work for the Undo/Redo History when editing a drawing. +- As imaginary visual lines superimposed on top of a drawing, for example to + visualize the link between two doodads or to draw collision hitboxes and other + debug lines to the drawing. +*/ +type Stroke struct { + ID int // Unique ID per each stroke + Shape Shape + Color render.Color + ExtraData interface{} // arbitrary storage for extra data to attach + + // Start and end points for Lines, Rectangles, etc. + PointA render.Point + PointB render.Point + + // Array of points for Freehand shapes. + Points []render.Point + uniqPoint map[render.Point]interface{} // deduplicate points added +} + +var nextStrokeID int + +// NewStroke initializes a new Stroke with a shape and a color. +func NewStroke(shape Shape, color render.Color) *Stroke { + nextStrokeID++ + return &Stroke{ + ID: nextStrokeID, + Shape: shape, + Color: color, + + // Initialize data structures. + Points: []render.Point{}, + uniqPoint: map[render.Point]interface{}{}, + } +} + +// IterPoints returns an iterator of points represented by the stroke. +// +// For a Line, returns all of the points between PointA and PointB. For freehand, +// returns every point added to the stroke. +func (s *Stroke) IterPoints() chan render.Point { + ch := make(chan render.Point) + go func() { + switch s.Shape { + case Freehand: + for _, point := range s.Points { + ch <- point + } + case Line: + for point := range render.IterLine2(s.PointA, s.PointB) { + ch <- point + } + } + close(ch) + }() + return ch +} + +// AddPoint adds a point to the stroke, for freehand shapes. +func (s *Stroke) AddPoint(p render.Point) { + if _, ok := s.uniqPoint[p]; ok { + return + } + s.uniqPoint[p] = nil + s.Points = append(s.Points, p) +} diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index 88b86a4..33b1a56 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -154,6 +154,16 @@ func (s *EditorScene) Loop(d *Doodle, ev *events.State) error { } } + // Undo/Redo key bindings. + if ev.ControlActive.Now { + key := ev.KeyName.Read() + if key == "z" { + s.UI.Canvas.UndoStroke() + } else if key == "y" { + s.UI.Canvas.RedoStroke() + } + } + s.UI.Loop(ev) // Switching to Play Mode? diff --git a/pkg/level/fmt_json.go b/pkg/level/fmt_json.go index 9a07e5b..450afed 100644 --- a/pkg/level/fmt_json.go +++ b/pkg/level/fmt_json.go @@ -12,7 +12,7 @@ import ( // FromJSON loads a level from JSON string. func FromJSON(filename string, data []byte) (*Level, error) { - var m = &Level{} + var m = New() err := json.Unmarshal(data, m) // Fill in defaults. diff --git a/pkg/level/types.go b/pkg/level/types.go index fa64593..6863500 100644 --- a/pkg/level/types.go +++ b/pkg/level/types.go @@ -6,6 +6,7 @@ import ( "git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/drawtool" ) // Useful variables. @@ -46,6 +47,9 @@ type Level struct { // Actors keep a list of the doodad instances in this map. Actors ActorMap `json:"actors" msgpack:"18"` + + // Undo history, temporary live data not persisted to the level file. + UndoHistory *drawtool.History `json:"-" msgpack:"-"` } // New creates a blank level object with all its members initialized. @@ -62,6 +66,8 @@ func New() *Level { Wallpaper: DefaultWallpaper, MaxWidth: 2550, MaxHeight: 3300, + + UndoHistory: drawtool.NewHistory(balance.UndoHistory), } } diff --git a/pkg/play_scene.go b/pkg/play_scene.go index 36dce4b..4992ef3 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -286,7 +286,7 @@ func (s *PlayScene) Loop(d *Doodle, ev *events.State) error { } // Switching to Edit Mode? - if ev.KeyName.Read() == "e" { + if s.CanEdit && ev.KeyName.Read() == "e" { s.EditLevel() return nil } diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 4d92ead..111eaa9 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -12,6 +12,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/bindata" "git.kirsle.net/apps/doodle/pkg/doodads" + "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/scripting" @@ -45,6 +46,7 @@ type Canvas struct { NoLimitScroll bool // Underlying chunk data for the drawing. + level *level.Level chunks *level.Chunker // Actors to superimpose on top of the drawing. @@ -73,6 +75,14 @@ type Canvas struct { OnLinkActors func(a, b *Actor) linkFirst *Actor + /******** + * Editable canvas private variables. + ********/ + // The current stroke actively being drawn by the user, during a + // mousedown-and-dragging event. + currentStroke *drawtool.Stroke + strokes map[int]*drawtool.Stroke // active stroke mapped by ID + // Tracking pixels while editing. TODO: get rid of pixelHistory? pixelHistory []*level.Pixel lastPixel *level.Pixel @@ -93,6 +103,8 @@ func NewCanvas(size int, editable bool) *Canvas { chunks: level.NewChunker(size), actors: make([]*Actor, 0), wallpaper: &Wallpaper{}, + + strokes: map[int]*drawtool.Stroke{}, } w.setup() w.IDFunc(func() string { @@ -125,6 +137,7 @@ func (w *Canvas) Load(p *level.Palette, g *level.Chunker) { // LoadLevel initializes a Canvas from a Level object. func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) { + w.level = level w.Load(level.Palette, level.Chunker) // TODO: wallpaper paths diff --git a/pkg/uix/canvas_editable.go b/pkg/uix/canvas_editable.go index 6d3de9c..1741110 100644 --- a/pkg/uix/canvas_editable.go +++ b/pkg/uix/canvas_editable.go @@ -4,6 +4,7 @@ import ( "git.kirsle.net/apps/doodle/lib/events" "git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/ui" + "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/level" ) @@ -28,6 +29,13 @@ func (w *Canvas) loopEditable(ev *events.State) error { // Clicking? Log all the pixels while doing so. if ev.Button1.Now { + // Initialize a new Stroke for this atomic drawing operation? + if w.currentStroke == nil { + w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) + w.currentStroke.ExtraData = w.Palette.ActiveSwatch + w.AddStroke(w.currentStroke) + } + lastPixel := w.lastPixel pixel := &level.Pixel{ X: cursor.X, @@ -50,7 +58,7 @@ func (w *Canvas) loopEditable(ev *events.State) error { // Draw the pixels in between. if lastPixel != pixel { for point := range render.IterLine(lastPixel.X, lastPixel.Y, pixel.X, pixel.Y) { - w.chunks.Set(point, lastPixel.Swatch) + w.currentStroke.AddPoint(point) } } } @@ -58,10 +66,28 @@ func (w *Canvas) loopEditable(ev *events.State) error { w.lastPixel = pixel w.pixelHistory = append(w.pixelHistory, pixel) - // Save in the pixel canvas map. - w.chunks.Set(cursor, pixel.Swatch) + // Save the pixel in the current stroke. + w.currentStroke.AddPoint(render.Point{ + X: cursor.X, + Y: cursor.Y, + }) } } else { + // Mouse released, commit the points to the drawing. + if w.currentStroke != nil { + for _, pt := range w.currentStroke.Points { + w.chunks.Set(pt, w.Palette.ActiveSwatch) + } + + // Add the stroke to level history. + if w.level != nil { + w.level.UndoHistory.AddStroke(w.currentStroke) + } + + w.RemoveStroke(w.currentStroke) + w.currentStroke = nil + } + w.lastPixel = nil } case ActorTool: diff --git a/pkg/uix/canvas_present.go b/pkg/uix/canvas_present.go index 43b2bbd..2904fa3 100644 --- a/pkg/uix/canvas_present.go +++ b/pkg/uix/canvas_present.go @@ -133,6 +133,8 @@ func (w *Canvas) Present(e render.Engine, p render.Point) { w.drawActors(e, p) + w.presentStrokes(e) + // XXX: Debug, show label in canvas corner. if balance.DebugCanvasLabel { rows := []string{ diff --git a/pkg/uix/canvas_strokes.go b/pkg/uix/canvas_strokes.go new file mode 100644 index 0000000..dac3a7d --- /dev/null +++ b/pkg/uix/canvas_strokes.go @@ -0,0 +1,112 @@ +package uix + +import ( + "git.kirsle.net/apps/doodle/lib/render" + "git.kirsle.net/apps/doodle/lib/ui" + "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/drawtool" + "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/log" +) + +// canvas_strokes.go: functions related to drawtool.Stroke and the Canvas. + +// AddStroke installs a new Stroke to be superimposed over drawing data +// in the canvas. +// +// The stroke is added to the canvas's map by its ID so it can be removed later. +// The stroke must have a non-zero ID value set or this function will panic. +// drawtool.NewStroke() creates an initialized Stroke object to use here. +func (w *Canvas) AddStroke(stroke *drawtool.Stroke) { + if stroke.ID == 0 { + panic("Canvas.AddStroke: the Stroke is missing an ID; was it initialized properly?") + } + + w.strokes[stroke.ID] = stroke +} + +// RemoveStroke uninstalls a Stroke from the canvas using its ID. +// +// Returns true if the stroke existed to begin with, false if not. +func (w *Canvas) RemoveStroke(stroke *drawtool.Stroke) bool { + if _, ok := w.strokes[stroke.ID]; ok { + delete(w.strokes, stroke.ID) + return true + } + return false +} + +// UndoStroke rolls back the level's UndoHistory and deletes the pixels last +// added to the level. Returns false and emits a warning to the log if the +// canvas has no level loaded properly. +func (w *Canvas) UndoStroke() bool { + if w.level == nil { + log.Error("Canvas.UndoStroke: no Level currently available to the canvas") + return false + } + + latest := w.level.UndoHistory.Latest() + if latest != nil { + for point := range latest.IterPoints() { + w.chunks.Delete(point) + } + } + return w.level.UndoHistory.Undo() +} + +// RedoStroke rolls the level's UndoHistory forwards again and replays the +// recently undone changes. +func (w *Canvas) RedoStroke() bool { + if w.level == nil { + log.Error("Canvas.UndoStroke: no Level currently available to the canvas") + return false + } + + ok := w.level.UndoHistory.Redo() + if !ok { + return false + } + + latest := w.level.UndoHistory.Latest() + + // We stored the ActiveSwatch on this stroke as we drew it. Recover it + // and place the pixels back down. + if swatch, ok := latest.ExtraData.(*level.Swatch); ok { + for point := range latest.IterPoints() { + w.chunks.Set(point, swatch) + } + return true + } + + log.Error("Canvas.UndoStroke: undo was successful but no Swatch was stored on the Stroke.ExtraData!") + + return ok +} + +// presentStrokes is called as part of Present() and draws the strokes whose +// pixels are currently visible within the viewport. +func (w *Canvas) presentStrokes(e render.Engine) { + var ( + P = ui.AbsolutePosition(w) // w.Point() // Canvas point in UI + VP = w.ViewportRelative() // Canvas scroll viewport + ) + + for _, stroke := range w.strokes { + for point := range stroke.IterPoints() { + if !point.Inside(VP) { + continue + } + + dest := render.Point{ + X: P.X + w.Scroll.X + w.BoxThickness(1) + point.X, + Y: P.Y + w.Scroll.Y + w.BoxThickness(1) + point.Y, + } + + if balance.DebugCanvasStrokeColor != render.Invisible { + e.DrawPoint(balance.DebugCanvasStrokeColor, dest) + } else { + e.DrawPoint(stroke.Color, dest) + } + } + } +}