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.
physics
Noah 2019-07-03 16:22:30 -07:00
parent 0c22ecae5e
commit c8620f871e
17 changed files with 638 additions and 30 deletions

78
TODO.md Normal file
View File

@ -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"
- [ ] ``

View File

@ -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{},
}
}

View File

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

View File

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

View File

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

131
pkg/drawtool/history.go Normal file
View File

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

View File

@ -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())
}

11
pkg/drawtool/shapes.go Normal file
View File

@ -0,0 +1,11 @@
package drawtool
// Shape of a stroke line.
type Shape int
// Shape values.
const (
Freehand Shape = iota
Line
Rectangle
)

77
pkg/drawtool/stroke.go Normal file
View File

@ -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)
}

View File

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

View File

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

View File

@ -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),
}
}

View File

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

View File

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

View File

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

View File

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

112
pkg/uix/canvas_strokes.go Normal file
View File

@ -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)
}
}
}
}