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.
This commit is contained in:
parent
0c22ecae5e
commit
c8620f871e
78
TODO.md
Normal file
78
TODO.md
Normal 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"
|
||||
- [ ] ``
|
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
131
pkg/drawtool/history.go
Normal 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
|
||||
}
|
132
pkg/drawtool/history_test.go
Normal file
132
pkg/drawtool/history_test.go
Normal 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
11
pkg/drawtool/shapes.go
Normal 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
77
pkg/drawtool/stroke.go
Normal 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)
|
||||
}
|
|
@ -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?
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
112
pkg/uix/canvas_strokes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user