From c8620f871e57a6f0743b2b4a8b11dad004b87d07 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 3 Jul 2019 16:22:30 -0700 Subject: [PATCH] Drawing Strokes and Undo/Redo Functionality * Add new pkg/drawtool with utilities to abstract away drawing actions into Strokes and track undo/redo History for them. * The freehand Pencil tool in EditorMode has been refactored to create a Stroke of Shape=Freehand and queue up its world pixels there instead of directly modifying the level chunker in real time. When the mouse button is released, the freehand Stroke is committed to the level chunker and added to the UndoHistory. * UndoHistory is (temporarily) stored with the level.Level so it can survive trips to PlayScene and back, but is not stored as JSON on disk. * Ctrl-Z and Ctrl-Y in EditorMode for undo and redo, respectively. --- TODO.md | 78 +++++++++++++++++++++ lib/events/events.go | 46 ++++++------ lib/render/sdl/events.go | 7 +- pkg/balance/debug.go | 4 ++ pkg/balance/numbers.go | 3 + pkg/drawtool/history.go | 131 ++++++++++++++++++++++++++++++++++ pkg/drawtool/history_test.go | 132 +++++++++++++++++++++++++++++++++++ pkg/drawtool/shapes.go | 11 +++ pkg/drawtool/stroke.go | 77 ++++++++++++++++++++ pkg/editor_scene.go | 10 +++ pkg/level/fmt_json.go | 2 +- pkg/level/types.go | 6 ++ pkg/play_scene.go | 2 +- pkg/uix/canvas.go | 13 ++++ pkg/uix/canvas_editable.go | 32 ++++++++- pkg/uix/canvas_present.go | 2 + pkg/uix/canvas_strokes.go | 112 +++++++++++++++++++++++++++++ 17 files changed, 638 insertions(+), 30 deletions(-) create mode 100644 TODO.md create mode 100644 pkg/drawtool/history.go create mode 100644 pkg/drawtool/history_test.go create mode 100644 pkg/drawtool/shapes.go create mode 100644 pkg/drawtool/stroke.go create mode 100644 pkg/uix/canvas_strokes.go 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) + } + } + } +}