Eraser Tool, Brush Sizes

* Implement Brush Sizes for drawtool.Stroke and add a UI to the tools panel
  to control the brush size.
  * Brush sizes: 1, 2, 4, 8, 16, 24, 32, 48, 64
* Add the Eraser Tool to editor mode. It uses a default brush size of 16
  and a max size of 32 due to some performance issues.
* The Undo/Redo system now remembers the original color of pixels when
  you change them, so that Undo will set them back how they were instead
  of deleting the pixel entirely. Due to performance issues, this only
  happens when your Brush Size is 0 (drawing single-pixel shapes).
* UI: Add an IntVariable option to ui.Label to bind showing the value of
  an int reference.

Aforementioned performance issues:

* When we try to remember whole rects of pixels for drawing thick
  shapes, it requires a ton of scanning for each step of the shape. Even
  de-duplicating pixel checks, tons of extra reads are constantly
  checked.
* The Eraser is the only tool that absolutely needs to be able to
  remember wiped pixels AND have large brush sizes. The performance
  sucks and lags a bit if you erase a lot all at once, but it's a
  trade-off for now.
* So pixels aren't remembered when drawing lines in your level with
  thick brushes, so the Undo action will simply delete your pixels and not
  reset them. Only the Eraser can bring back pixels.
physics
Noah 2019-07-11 19:07:46 -07:00
parent 7317615318
commit cc1e441232
20 changed files with 548 additions and 114 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

View File

@ -254,6 +254,7 @@ func IterRect(p1, p2 Point) chan Point {
X: TopLeft.X, X: TopLeft.X,
Y: BottomRight.Y, Y: BottomRight.Y,
} }
dedupe = map[Point]interface{}{}
) )
// Trace all four edges and yield it. // Trace all four edges and yield it.
@ -268,7 +269,10 @@ func IterRect(p1, p2 Point) chan Point {
} }
for _, edge := range edges { for _, edge := range edges {
for pt := range IterLine2(edge.A, edge.B) { for pt := range IterLine2(edge.A, edge.B) {
generator <- pt if _, ok := dedupe[pt]; !ok {
generator <- pt
dedupe[pt] = nil
}
} }
} }

View File

@ -20,6 +20,7 @@ type Label struct {
// Configurable fields for the constructor. // Configurable fields for the constructor.
Text string Text string
TextVariable *string TextVariable *string
IntVariable *int
Font render.Text Font render.Text
width int32 width int32
@ -32,6 +33,7 @@ func NewLabel(c Label) *Label {
w := &Label{ w := &Label{
Text: c.Text, Text: c.Text,
TextVariable: c.TextVariable, TextVariable: c.TextVariable,
IntVariable: c.IntVariable,
Font: DefaultFont, Font: DefaultFont,
} }
if !c.Font.IsZero() { if !c.Font.IsZero() {
@ -49,6 +51,9 @@ func (w *Label) text() render.Text {
if w.TextVariable != nil { if w.TextVariable != nil {
w.Font.Text = *w.TextVariable w.Font.Text = *w.TextVariable
return w.Font return w.Font
} else if w.IntVariable != nil {
w.Font.Text = fmt.Sprintf("%d", *w.IntVariable)
return w.Font
} }
w.Font.Text = w.Text w.Font.Text = w.Text
return w.Font return w.Font

View File

@ -173,7 +173,7 @@ func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSl
} }
) )
if XY.X >= P.X && XY.X <= P2.X && XY.Y >= P.Y && XY.Y <= P2.Y { if XY.X >= P.X && XY.X < P2.X && XY.Y >= P.Y && XY.Y < P2.Y {
// Cursor intersects the widget. // Cursor intersects the widget.
hovering = append(hovering, child) hovering = append(hovering, child)
} else { } else {

View File

@ -27,6 +27,22 @@ var (
// Size of Undo/Redo history for map editor. // Size of Undo/Redo history for map editor.
UndoHistory = 20 UndoHistory = 20
// Options for brush size.
BrushSizeOptions = []int{
0,
1,
2,
4,
8,
16,
24,
32,
48,
64,
}
DefaultEraserBrushSize = 8
MaxEraserBrushSize = 32 // the bigger, the slower
) )
// Edit Mode Values // Edit Mode Values

View File

@ -52,6 +52,14 @@ var (
Color: render.Black, Color: render.Black,
} }
// SmallMonoFont for cramped spaces like the +/- buttons on Toolbar.
SmallMonoFont = render.Text{
Size: 14,
PadX: 3,
FontFilename: "DejaVuSansMono.ttf",
Color: render.Black,
}
// Color for draggable doodad. // Color for draggable doodad.
DragColor = render.MustHexColor("#0099FF") DragColor = render.MustHexColor("#0099FF")

View File

@ -113,6 +113,7 @@ func (d *Doodle) Run() error {
// Poll for events. // Poll for events.
ev, err := d.Engine.Poll() ev, err := d.Engine.Poll()
shmem.Cursor = render.NewPoint(ev.CursorX.Now, ev.CursorY.Now)
if ev.EnterKey.Now { if ev.EnterKey.Now {
log.Info("MainLoop sees enter key now") log.Info("MainLoop sees enter key now")
} }

View File

@ -8,4 +8,5 @@ const (
Freehand Shape = iota Freehand Shape = iota
Line Line
Rectangle Rectangle
Eraser // not really a shape but communicates the intention
) )

View File

@ -18,6 +18,7 @@ type Stroke struct {
ID int // Unique ID per each stroke ID int // Unique ID per each stroke
Shape Shape Shape Shape
Color render.Color Color render.Color
Thickness int // 0 = 1px; thickness creates a box N pixels away from each point
ExtraData interface{} // arbitrary storage for extra data to attach ExtraData interface{} // arbitrary storage for extra data to attach
// Start and end points for Lines, Rectangles, etc. // Start and end points for Lines, Rectangles, etc.
@ -27,6 +28,16 @@ type Stroke struct {
// Array of points for Freehand shapes. // Array of points for Freehand shapes.
Points []render.Point Points []render.Point
uniqPoint map[render.Point]interface{} // deduplicate points added uniqPoint map[render.Point]interface{} // deduplicate points added
// Storage space to recall the previous values of points that were replaced,
// especially for the Undo/Redo History tool. When the uix.Canvas commits a
// Stroke to the level data, any pixel that has replaced an existing color
// will cache its color here, so we can easily page forwards and backwards
// in history and not lose data.
//
// The data is implementation defined and controlled by the caller. This
// package does not modify OriginalPoints or do anything with it.
OriginalPoints map[render.Point]interface{}
} }
var nextStrokeID int var nextStrokeID int
@ -42,6 +53,8 @@ func NewStroke(shape Shape, color render.Color) *Stroke {
// Initialize data structures. // Initialize data structures.
Points: []render.Point{}, Points: []render.Point{},
uniqPoint: map[render.Point]interface{}{}, uniqPoint: map[render.Point]interface{}{},
OriginalPoints: map[render.Point]interface{}{},
} }
} }
@ -49,9 +62,11 @@ func NewStroke(shape Shape, color render.Color) *Stroke {
func (s *Stroke) Copy() *Stroke { func (s *Stroke) Copy() *Stroke {
nextStrokeID++ nextStrokeID++
return &Stroke{ return &Stroke{
ID: nextStrokeID, ID: nextStrokeID,
Shape: s.Shape, Shape: s.Shape,
Color: s.Color, Color: s.Color,
Thickness: s.Thickness,
ExtraData: s.ExtraData,
Points: []render.Point{}, Points: []render.Point{},
uniqPoint: map[render.Point]interface{}{}, uniqPoint: map[render.Point]interface{}{},
@ -66,6 +81,8 @@ func (s *Stroke) IterPoints() chan render.Point {
ch := make(chan render.Point) ch := make(chan render.Point)
go func() { go func() {
switch s.Shape { switch s.Shape {
case Eraser:
fallthrough
case Freehand: case Freehand:
for _, point := range s.Points { for _, point := range s.Points {
ch <- point ch <- point
@ -84,6 +101,23 @@ func (s *Stroke) IterPoints() chan render.Point {
return ch return ch
} }
// IterThickPoints iterates over the points and yield Rects of each one.
func (s *Stroke) IterThickPoints() chan render.Rect {
ch := make(chan render.Rect)
go func() {
for pt := range s.IterPoints() {
ch <- render.Rect{
X: pt.X - int32(s.Thickness),
Y: pt.Y - int32(s.Thickness),
W: int32(s.Thickness) * 2,
H: int32(s.Thickness) * 2,
}
}
close(ch)
}()
return ch
}
// AddPoint adds a point to the stroke, for freehand shapes. // AddPoint adds a point to the stroke, for freehand shapes.
func (s *Stroke) AddPoint(p render.Point) { func (s *Stroke) AddPoint(p render.Point) {
if _, ok := s.uniqPoint[p]; ok { if _, ok := s.uniqPoint[p]; ok {

View File

@ -10,6 +10,7 @@ const (
RectTool RectTool
ActorTool // drag and move actors ActorTool // drag and move actors
LinkTool LinkTool
EraserTool
) )
var toolNames = []string{ var toolNames = []string{
@ -18,6 +19,7 @@ var toolNames = []string{
"Rectangle", "Rectangle",
"Doodad", // readable name for ActorTool "Doodad", // readable name for ActorTool
"Link", "Link",
"Eraser",
} }
func (t Tool) String() string { func (t Tool) String() string {

View File

@ -227,7 +227,6 @@ func (s *EditorScene) LoadLevel(filename string) error {
s.filename = filename s.filename = filename
level, err := level.LoadFile(filename) level, err := level.LoadFile(filename)
fmt.Printf("%+v\n", level)
if err != nil { if err != nil {
return fmt.Errorf("EditorScene.LoadLevel(%s): %s", filename, err) return fmt.Errorf("EditorScene.LoadLevel(%s): %s", filename, err)
} }

View File

@ -216,8 +216,6 @@ func (u *EditorUI) scrollDoodadFrame(rows int) {
u.doodadSkip = 0 u.doodadSkip = 0
} }
log.Info("scrollDoodadFrame(%d): skip=%d", rows, u.doodadSkip)
// Calculate about how many rows we can see given our current window size. // Calculate about how many rows we can see given our current window size.
var ( var (
maxVisibleHeight = int32(u.d.height - 86) maxVisibleHeight = int32(u.d.height - 86)
@ -233,8 +231,6 @@ func (u *EditorUI) scrollDoodadFrame(rows int) {
maxSkip = 0 maxSkip = 0
} }
log.Info("maxSkip = (%d * %d) - (%d * %d) = %d", len(u.doodadRows), u.doodadButtonSize, u.doodadButtonSize, rowsEstimated, maxSkip)
// log.Info("maxSkip: estimate=%d rows=%d - visible=%d => %d", rowsEstimated, len(u.doodadRows), rowsVisible, maxSkip)
if u.doodadSkip > maxSkip { if u.doodadSkip > maxSkip {
u.doodadSkip = maxSkip u.doodadSkip = maxSkip
} }
@ -269,6 +265,5 @@ func (u *EditorUI) scrollDoodadFrame(rows int) {
u.doodadScroller.Configure(ui.Config{ u.doodadScroller.Configure(ui.Config{
Width: int32(float64(paletteWidth-50) * viewPercent), // TODO: hacky magic number Width: int32(float64(paletteWidth-50) * viewPercent), // TODO: hacky magic number
}) })
log.Info("v%% = (%d + %d) / %d = %f", rowsBefore, rowsVisible, len(u.doodadRows), viewPercent)
} }

View File

@ -3,8 +3,8 @@ package doodle
import ( import (
"git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/ui" "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/drawtool"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/sprites" "git.kirsle.net/apps/doodle/pkg/sprites"
) )
@ -27,6 +27,18 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Anchor: ui.N, Anchor: ui.N,
}) })
// Helper functions to toggle the correct palette panel.
var (
showSwatchPalette = func() {
u.DoodadTab.Hide()
u.PaletteTab.Show()
}
showDoodadPalette = func() {
u.PaletteTab.Hide()
u.DoodadTab.Show()
}
)
// Buttons. // Buttons.
var buttons = []struct { var buttons = []struct {
Value string Value string
@ -38,8 +50,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Icon: "assets/sprites/pencil-tool.png", Icon: "assets/sprites/pencil-tool.png",
Click: func() { Click: func() {
u.Canvas.Tool = drawtool.PencilTool u.Canvas.Tool = drawtool.PencilTool
u.DoodadTab.Hide() showSwatchPalette()
u.PaletteTab.Show()
d.Flash("Pencil Tool selected.") d.Flash("Pencil Tool selected.")
}, },
}, },
@ -49,8 +60,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Icon: "assets/sprites/line-tool.png", Icon: "assets/sprites/line-tool.png",
Click: func() { Click: func() {
u.Canvas.Tool = drawtool.LineTool u.Canvas.Tool = drawtool.LineTool
u.DoodadTab.Hide() showSwatchPalette()
u.PaletteTab.Show()
d.Flash("Line Tool selected.") d.Flash("Line Tool selected.")
}, },
}, },
@ -60,8 +70,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Icon: "assets/sprites/rect-tool.png", Icon: "assets/sprites/rect-tool.png",
Click: func() { Click: func() {
u.Canvas.Tool = drawtool.RectTool u.Canvas.Tool = drawtool.RectTool
u.DoodadTab.Hide() showSwatchPalette()
u.PaletteTab.Show()
d.Flash("Rectangle Tool selected.") d.Flash("Rectangle Tool selected.")
}, },
}, },
@ -71,8 +80,7 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Icon: "assets/sprites/actor-tool.png", Icon: "assets/sprites/actor-tool.png",
Click: func() { Click: func() {
u.Canvas.Tool = drawtool.ActorTool u.Canvas.Tool = drawtool.ActorTool
u.PaletteTab.Hide() showDoodadPalette()
u.DoodadTab.Show()
d.Flash("Actor Tool selected. Drag a Doodad from the drawer into your level.") d.Flash("Actor Tool selected. Drag a Doodad from the drawer into your level.")
}, },
}, },
@ -82,11 +90,28 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
Icon: "assets/sprites/link-tool.png", Icon: "assets/sprites/link-tool.png",
Click: func() { Click: func() {
u.Canvas.Tool = drawtool.LinkTool u.Canvas.Tool = drawtool.LinkTool
u.PaletteTab.Hide() showDoodadPalette()
u.DoodadTab.Show()
d.Flash("Link Tool selected. Click a doodad in your level to link it to another.") d.Flash("Link Tool selected. Click a doodad in your level to link it to another.")
}, },
}, },
{
Value: drawtool.EraserTool.String(),
Icon: "assets/sprites/eraser-tool.png",
Click: func() {
u.Canvas.Tool = drawtool.EraserTool
// Set the brush size within range for the eraser.
if u.Canvas.BrushSize < balance.DefaultEraserBrushSize {
u.Canvas.BrushSize = balance.DefaultEraserBrushSize
} else if u.Canvas.BrushSize > balance.MaxEraserBrushSize {
u.Canvas.BrushSize = balance.MaxEraserBrushSize
}
showSwatchPalette()
d.Flash("Eraser Tool selected.")
},
},
} }
for _, button := range buttons { for _, button := range buttons {
button := button button := button
@ -103,7 +128,6 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
) )
var btnSize int32 = btn.BoxThickness(2) + toolbarSpriteSize var btnSize int32 = btn.BoxThickness(2) + toolbarSpriteSize
log.Info("BtnSize: %d", btnSize)
btn.Resize(render.NewRect(btnSize, btnSize)) btn.Resize(render.NewRect(btnSize, btnSize))
btn.Handle(ui.Click, func(p render.Point) { btn.Handle(ui.Click, func(p render.Point) {
@ -117,6 +141,102 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame {
}) })
} }
// Spacer frame.
frame.Pack(ui.NewFrame("spacer"), ui.Pack{
Anchor: ui.N,
PadY: 8,
})
// "Brush Size" label
bsLabel := ui.NewLabel(ui.Label{
Text: "Size:",
Font: balance.LabelFont,
})
frame.Pack(bsLabel, ui.Pack{
Anchor: ui.N,
})
// Brush Size widget
{
sizeFrame := ui.NewFrame("Brush Size Frame")
frame.Pack(sizeFrame, ui.Pack{
Anchor: ui.N,
PadY: 0,
})
sizeLabel := ui.NewLabel(ui.Label{
IntVariable: &u.Canvas.BrushSize,
Font: balance.SmallMonoFont,
})
sizeLabel.Configure(ui.Config{
BorderSize: 1,
BorderStyle: ui.BorderSunken,
Background: render.Grey,
})
sizeFrame.Pack(sizeLabel, ui.Pack{
Anchor: ui.N,
FillX: true,
PadY: 2,
})
sizeBtnFrame := ui.NewFrame("Size Increment Button Frame")
sizeFrame.Pack(sizeBtnFrame, ui.Pack{
Anchor: ui.N,
FillX: true,
})
var incButtons = []struct {
Label string
F func()
}{
{
Label: "-",
F: func() {
// Select next smaller brush size.
for i := len(balance.BrushSizeOptions) - 1; i >= 0; i-- {
if balance.BrushSizeOptions[i] < u.Canvas.BrushSize {
u.Canvas.BrushSize = balance.BrushSizeOptions[i]
break
}
}
},
},
{
Label: "+",
F: func() {
// Select next bigger brush size.
for _, size := range balance.BrushSizeOptions {
if size > u.Canvas.BrushSize {
u.Canvas.BrushSize = size
break
}
}
// Limit the eraser brush size, too big and it's slow because
// the eraser has to scan and remember pixels to be able to
// Undo the erase and restore them.
if u.Canvas.Tool == drawtool.EraserTool && u.Canvas.BrushSize > balance.MaxEraserBrushSize {
u.Canvas.BrushSize = balance.MaxEraserBrushSize
}
},
},
}
for _, button := range incButtons {
button := button
btn := ui.NewButton("BrushSize"+button.Label, ui.NewLabel(ui.Label{
Text: button.Label,
Font: balance.SmallMonoFont,
}))
btn.Handle(ui.Click, func(p render.Point) {
button.F()
})
u.Supervisor.Add(btn)
sizeBtnFrame.Pack(btn, ui.Pack{
Anchor: ui.W,
})
}
}
frame.Compute(d.Engine) frame.Compute(d.Engine)
return frame return frame

View File

@ -204,6 +204,23 @@ func (c *Chunker) Set(p render.Point, sw *Swatch) error {
return chunk.Set(p, sw) return chunk.Set(p, sw)
} }
// SetRect sets a rectangle of pixels to a color all at once.
func (c *Chunker) SetRect(r render.Rect, sw *Swatch) error {
var (
xMin = r.X
yMin = r.Y
xMax = r.X + r.W
yMax = r.Y + r.H
)
for x := xMin; x < xMax; x++ {
for y := yMin; y < yMax; y++ {
c.Set(render.NewPoint(x, y), sw)
}
}
return nil
}
// Delete a pixel at the given coordinate. // Delete a pixel at the given coordinate.
func (c *Chunker) Delete(p render.Point) error { func (c *Chunker) Delete(p render.Point) error {
coord := c.ChunkCoordinate(p) coord := c.ChunkCoordinate(p)
@ -213,6 +230,25 @@ func (c *Chunker) Delete(p render.Point) error {
return fmt.Errorf("no chunk %s exists for point %s", coord, p) return fmt.Errorf("no chunk %s exists for point %s", coord, p)
} }
// DeleteRect deletes a rectangle of pixels between two points.
// The rect is a relative one with a width and height, and the X,Y values are
// an absolute world coordinate.
func (c *Chunker) DeleteRect(r render.Rect) error {
var (
xMin = r.X
yMin = r.Y
xMax = r.X + r.W
yMax = r.Y + r.H
)
for x := xMin; x < xMax; x++ {
for y := yMin; y < yMax; y++ {
c.Delete(render.NewPoint(x, y))
}
}
return nil
}
// ChunkCoordinate computes a chunk coordinate from an absolute coordinate. // ChunkCoordinate computes a chunk coordinate from an absolute coordinate.
func (c *Chunker) ChunkCoordinate(abs render.Point) render.Point { func (c *Chunker) ChunkCoordinate(abs render.Point) render.Point {
if c.Size == 0 { if c.Size == 0 {

View File

@ -12,6 +12,9 @@ var (
// Tick is incremented by the main game loop each frame. // Tick is incremented by the main game loop each frame.
Tick uint64 Tick uint64
// Current position of the cursor relative to the window.
Cursor render.Point
// Current render engine (i.e. SDL2 or HTML5 Canvas) // Current render engine (i.e. SDL2 or HTML5 Canvas)
// The level.Chunk.ToBitmap() uses this to cache a texture image. // The level.Chunk.ToBitmap() uses this to cache a texture image.
CurrentRenderEngine render.Engine CurrentRenderEngine render.Engine

View File

@ -31,7 +31,8 @@ type Canvas struct {
Scrollable bool // Cursor keys will scroll the viewport of this canvas. Scrollable bool // Cursor keys will scroll the viewport of this canvas.
// Selected draw tool/mode, default Pencil, for editable canvases. // Selected draw tool/mode, default Pencil, for editable canvases.
Tool drawtool.Tool Tool drawtool.Tool
BrushSize int // thickness of selected brush
// MaskColor will force every pixel to render as this color regardless of // MaskColor will force every pixel to render as this color regardless of
// the palette index of that pixel. Otherwise pixels behave the same and // the palette index of that pixel. Otherwise pixels behave the same and
@ -86,10 +87,7 @@ type Canvas struct {
// mousedown-and-dragging event. // mousedown-and-dragging event.
currentStroke *drawtool.Stroke currentStroke *drawtool.Stroke
strokes map[int]*drawtool.Stroke // active stroke mapped by ID strokes map[int]*drawtool.Stroke // active stroke mapped by ID
lastPixel *level.Pixel
// Tracking pixels while editing. TODO: get rid of pixelHistory?
pixelHistory []*level.Pixel
lastPixel *level.Pixel
// We inherit the ui.Widget which manages the width and height. // We inherit the ui.Widget which manages the width and height.
Scroll render.Point // Scroll offset for which parts of canvas are visible. Scroll render.Point // Scroll offset for which parts of canvas are visible.

50
pkg/uix/canvas_cursor.go Normal file
View File

@ -0,0 +1,50 @@
package uix
import (
"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/ui"
"git.kirsle.net/apps/doodle/pkg/shmem"
)
// IsCursorOver returns true if the mouse cursor is physically over top
// of the canvas's widget space.
func (w *Canvas) IsCursorOver() bool {
var (
P = ui.AbsolutePosition(w)
S = w.Size()
)
return shmem.Cursor.Inside(render.Rect{
X: P.X,
Y: P.Y,
W: S.W,
H: S.H,
})
}
// presentCursor draws something at the mouse cursor on the Canvas.
//
// This is currently used in Edit Mode when you're drawing a shape with a thick
// brush size, and draws a "preview rect" under the cursor of how big a click
// will be at that size.
func (w *Canvas) presentCursor(e render.Engine) {
if !w.IsCursorOver() {
return
}
// Are we editing with a thick brush?
if w.BrushSize > 0 {
var r = int32(w.BrushSize)
rect := render.Rect{
X: shmem.Cursor.X - r,
Y: shmem.Cursor.Y - r,
W: r * 2,
H: r * 2,
}
e.DrawRect(render.Black, rect)
rect.X++
rect.Y++
rect.W -= 2
rect.H -= 2
e.DrawRect(render.RGBA(153, 153, 153, 153), rect)
}
}

View File

@ -8,6 +8,93 @@ import (
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
) )
// commitStroke is the common function that applies a stroke the user is
// actively drawing onto the canvas. This is for Edit Mode.
func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) {
if w.currentStroke == nil {
// nothing to commit
return
}
var (
deleting = w.currentStroke.Shape == drawtool.Eraser
dedupe = map[render.Point]interface{}{} // don't revisit the same point twice
// Helper functions to set pixels on the level while storing the original
// value of any pixel being replaced.
set = func(pt render.Point, sw *level.Swatch) {
// Take note of what pixel was originally here before we change it.
if swatch, err := w.chunks.Get(pt); err == nil {
if _, ok := dedupe[pt]; !ok {
w.currentStroke.OriginalPoints[pt] = swatch
dedupe[pt] = nil
}
}
if deleting {
w.chunks.Delete(pt)
} else if sw != nil {
w.chunks.Set(pt, sw)
} else {
panic("Canvas.commitStroke.set: current stroke has no level.Swatch in ExtraData")
}
}
// Rects: read existing pixels first, then write new pixels
readRect = func(rect render.Rect) {
for pt := range w.chunks.IterViewport(rect) {
point := pt.Point()
if _, ok := dedupe[point]; !ok {
w.currentStroke.OriginalPoints[pt.Point()] = pt.Swatch
dedupe[point] = nil
}
}
}
setRect = func(rect render.Rect, sw *level.Swatch) {
if deleting {
w.chunks.DeleteRect(rect)
} else if sw != nil {
w.chunks.SetRect(rect, sw)
} else {
panic("Canvas.commitStroke.setRect: current stroke has no level.Swatch in ExtraData")
}
}
)
var swatch *level.Swatch
if v, ok := w.currentStroke.ExtraData.(*level.Swatch); ok {
swatch = v
}
if w.currentStroke.Thickness > 0 {
// Eraser Tool only: record which pixels will be blown away by this.
// This is SLOW for thick (rect-based) lines, but eraser tool must have it.
if deleting {
for rect := range w.currentStroke.IterThickPoints() {
readRect(rect)
}
}
for rect := range w.currentStroke.IterThickPoints() {
setRect(rect, swatch)
}
} else {
for pt := range w.currentStroke.IterPoints() {
// note: set already records the original pixel if changing it.
set(pt, swatch)
}
}
// Add the stroke to level history.
if w.level != nil && addHistory {
w.level.UndoHistory.AddStroke(w.currentStroke)
}
w.RemoveStroke(w.currentStroke)
w.currentStroke = nil
w.lastPixel = nil
}
// loopEditable handles the Loop() part for editable canvases. // loopEditable handles the Loop() part for editable canvases.
func (w *Canvas) loopEditable(ev *events.State) error { func (w *Canvas) loopEditable(ev *events.State) error {
// Get the absolute position of the canvas on screen to accurately match // Get the absolute position of the canvas on screen to accurately match
@ -20,6 +107,14 @@ func (w *Canvas) loopEditable(ev *events.State) error {
} }
) )
// If the actual cursor is not over the actual Canvas UI element, don't
// pay any attention to clicks. I added this when I saw you were able to
// accidentally draw (with large brush size) when clicking on the Palette
// panel and not the drawing itself.
if !w.IsCursorOver() {
return nil
}
switch w.Tool { switch w.Tool {
case drawtool.PencilTool: case drawtool.PencilTool:
// If no swatch is active, do nothing with mouse clicks. // If no swatch is active, do nothing with mouse clicks.
@ -32,6 +127,7 @@ func (w *Canvas) loopEditable(ev *events.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color)
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.AddStroke(w.currentStroke) w.AddStroke(w.currentStroke)
} }
@ -53,18 +149,15 @@ func (w *Canvas) loopEditable(ev *events.State) error {
} }
// Append unique new pixels. // Append unique new pixels.
if len(w.pixelHistory) == 0 || w.pixelHistory[len(w.pixelHistory)-1] != pixel { if lastPixel != nil || lastPixel != pixel {
if lastPixel != nil { // Draw the pixels in between.
// Draw the pixels in between. if lastPixel != nil && lastPixel != pixel {
if lastPixel != pixel { for point := range render.IterLine(lastPixel.X, lastPixel.Y, pixel.X, pixel.Y) {
for point := range render.IterLine(lastPixel.X, lastPixel.Y, pixel.X, pixel.Y) { w.currentStroke.AddPoint(point)
w.currentStroke.AddPoint(point)
}
} }
} }
w.lastPixel = pixel w.lastPixel = pixel
w.pixelHistory = append(w.pixelHistory, pixel)
// Save the pixel in the current stroke. // Save the pixel in the current stroke.
w.currentStroke.AddPoint(render.Point{ w.currentStroke.AddPoint(render.Point{
@ -73,22 +166,7 @@ func (w *Canvas) loopEditable(ev *events.State) error {
}) })
} }
} else { } else {
// Mouse released, commit the points to the drawing. w.commitStroke(w.Tool, true)
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 drawtool.LineTool: case drawtool.LineTool:
// If no swatch is active, do nothing with mouse clicks. // If no swatch is active, do nothing with mouse clicks.
@ -101,6 +179,7 @@ func (w *Canvas) loopEditable(ev *events.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color)
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
w.AddStroke(w.currentStroke) w.AddStroke(w.currentStroke)
@ -108,20 +187,7 @@ func (w *Canvas) loopEditable(ev *events.State) error {
w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y)
} else { } else {
// Mouse released, commit the points to the drawing. w.commitStroke(w.Tool, true)
if w.currentStroke != nil {
for pt := range render.IterLine2(w.currentStroke.PointA, w.currentStroke.PointB) {
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
}
} }
case drawtool.RectTool: case drawtool.RectTool:
// If no swatch is active, do nothing with mouse clicks. // If no swatch is active, do nothing with mouse clicks.
@ -134,6 +200,7 @@ func (w *Canvas) loopEditable(ev *events.State) error {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color)
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
w.AddStroke(w.currentStroke) w.AddStroke(w.currentStroke)
@ -141,20 +208,52 @@ func (w *Canvas) loopEditable(ev *events.State) error {
w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y) w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y)
} else { } else {
// Mouse released, commit the points to the drawing. w.commitStroke(w.Tool, true)
if w.currentStroke != nil { }
for pt := range render.IterRect(w.currentStroke.PointA, w.currentStroke.PointB) { case drawtool.EraserTool:
w.chunks.Set(pt, w.Palette.ActiveSwatch) // Clicking? Log all the pixels while doing so.
} if ev.Button1.Now {
// Initialize a new Stroke for this atomic drawing operation?
// Add the stroke to level history. if w.currentStroke == nil {
if w.level != nil { // The color is white, will look like white-out that covers the
w.level.UndoHistory.AddStroke(w.currentStroke) // wallpaper during the stroke.
} w.currentStroke = drawtool.NewStroke(drawtool.Eraser, render.White)
w.currentStroke.Thickness = w.BrushSize
w.RemoveStroke(w.currentStroke) w.AddStroke(w.currentStroke)
w.currentStroke = nil
} }
lastPixel := w.lastPixel
pixel := &level.Pixel{
X: cursor.X,
Y: cursor.Y,
Swatch: w.Palette.ActiveSwatch,
}
// If the user is holding the mouse down over one spot and not
// moving, don't do anything. The pixel has already been set and
// needless writes to the map cause needless cache rewrites etc.
if lastPixel != nil {
if pixel.X == lastPixel.X && pixel.Y == lastPixel.Y {
break
}
}
// Append unique new pixels.
if lastPixel == nil || lastPixel != pixel {
if lastPixel != nil && lastPixel != pixel {
for point := range render.IterLine(lastPixel.X, lastPixel.Y, pixel.X, pixel.Y) {
w.currentStroke.AddPoint(point)
}
}
w.lastPixel = pixel
w.currentStroke.AddPoint(render.Point{
X: cursor.X,
Y: cursor.Y,
})
}
} else {
w.commitStroke(w.Tool, true)
} }
case drawtool.ActorTool: case drawtool.ActorTool:
// See if any of the actors are below the mouse cursor. // See if any of the actors are below the mouse cursor.

View File

@ -132,8 +132,8 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
} }
w.drawActors(e, p) w.drawActors(e, p)
w.presentStrokes(e) w.presentStrokes(e)
w.presentCursor(e)
// XXX: Debug, show label in canvas corner. // XXX: Debug, show label in canvas corner.
if balance.DebugCanvasLabel { if balance.DebugCanvasLabel {

View File

@ -48,8 +48,48 @@ func (w *Canvas) UndoStroke() bool {
latest := w.level.UndoHistory.Latest() latest := w.level.UndoHistory.Latest()
if latest != nil { if latest != nil {
for point := range latest.IterPoints() { // TODO: only single-thickness lines will restore the original color;
w.chunks.Delete(point) // thick lines just delete their pixels from the world due to performance.
// But the Eraser Tool is always thick, which always should restore its
// pixels. Can't do anything about that, so the inefficient thick rect
// restore is used only for Eraser at least.
if latest.Thickness > 0 {
if latest.Shape == drawtool.Eraser {
for rect := range latest.IterThickPoints() {
var (
xMin = rect.X
xMax = rect.X + rect.W
yMin = rect.Y
yMax = rect.Y + rect.H
)
for x := xMin; x < xMax; x++ {
for y := yMin; y < yMax; y++ {
if v, ok := latest.OriginalPoints[render.NewPoint(x, y)]; ok {
if swatch, ok := v.(*level.Swatch); ok {
w.chunks.Set(render.NewPoint(x, y), swatch)
}
}
}
}
}
} else {
for rect := range latest.IterThickPoints() {
w.chunks.DeleteRect(rect)
}
}
} else {
for point := range latest.IterPoints() {
// Was there a previous swatch at this point to restore?
if v, ok := latest.OriginalPoints[point]; ok {
if swatch, ok := v.(*level.Swatch); ok {
w.chunks.Set(point, swatch)
continue
}
}
w.chunks.Delete(point)
}
} }
} }
return w.level.UndoHistory.Undo() return w.level.UndoHistory.Undo()
@ -72,14 +112,8 @@ func (w *Canvas) RedoStroke() bool {
// We stored the ActiveSwatch on this stroke as we drew it. Recover it // We stored the ActiveSwatch on this stroke as we drew it. Recover it
// and place the pixels back down. // and place the pixels back down.
if swatch, ok := latest.ExtraData.(*level.Swatch); ok { w.currentStroke = latest
for point := range latest.IterPoints() { w.commitStroke(w.Tool, false)
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 return ok
} }
@ -153,6 +187,7 @@ func (w *Canvas) presentActorLinks(e render.Engine) {
// Draw a line connecting the centers of each actor together. // Draw a line connecting the centers of each actor together.
stroke := drawtool.NewStroke(drawtool.Line, color) stroke := drawtool.NewStroke(drawtool.Line, color)
stroke.Thickness = 1
stroke.PointA = render.Point{ stroke.PointA = render.Point{
X: aP.X + (aS.W / 2), X: aP.X + (aS.W / 2),
Y: aP.Y + (aS.H / 2), Y: aP.Y + (aS.H / 2),
@ -163,16 +198,6 @@ func (w *Canvas) presentActorLinks(e render.Engine) {
} }
strokes = append(strokes, stroke) strokes = append(strokes, stroke)
// Make it double thick.
double := stroke.Copy()
double.PointA = render.NewPoint(stroke.PointA.X, stroke.PointA.Y+1)
double.PointB = render.NewPoint(stroke.PointB.X, stroke.PointB.Y+1)
strokes = append(strokes, double)
double = stroke.Copy()
double.PointA = render.NewPoint(stroke.PointA.X+1, stroke.PointA.Y)
double.PointB = render.NewPoint(stroke.PointB.X+1, stroke.PointB.Y)
strokes = append(strokes, double)
} }
} }
@ -183,14 +208,14 @@ func (w *Canvas) presentActorLinks(e render.Engine) {
// presentActorLinks to actually draw the lines to the canvas. // presentActorLinks to actually draw the lines to the canvas.
func (w *Canvas) drawStrokes(e render.Engine, strokes []*drawtool.Stroke) { func (w *Canvas) drawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
var ( var (
P = ui.AbsolutePosition(w) // w.Point() // Canvas point in UI P = ui.AbsolutePosition(w) // Canvas point in UI
VP = w.ViewportRelative() // Canvas scroll viewport VP = w.ViewportRelative() // Canvas scroll viewport
) )
for _, stroke := range strokes { for _, stroke := range strokes {
// If none of this stroke is in our viewport, don't waste time // If none of this stroke is in our viewport, don't waste time
// looping through it. // looping through it.
if stroke.Shape == drawtool.Freehand { if stroke.Shape == drawtool.Freehand || stroke.Shape == drawtool.Eraser {
if len(stroke.Points) >= 2 { if len(stroke.Points) >= 2 {
if !stroke.Points[0].Inside(VP) && !stroke.Points[len(stroke.Points)-1].Inside(VP) { if !stroke.Points[0].Inside(VP) && !stroke.Points[len(stroke.Points)-1].Inside(VP) {
continue continue
@ -206,20 +231,58 @@ func (w *Canvas) drawStrokes(e render.Engine, strokes []*drawtool.Stroke) {
} }
// Iter the points and draw what's visible. // Iter the points and draw what's visible.
for point := range stroke.IterPoints() { if stroke.Thickness > 0 {
if !point.Inside(VP) { for rect := range stroke.IterThickPoints() {
continue if !rect.Intersects(VP) {
} continue
}
dest := render.Point{ // Destination rectangle to draw to screen, taking into account
X: P.X + w.Scroll.X + w.BoxThickness(1) + point.X, // the position of the Canvas itself.
Y: P.Y + w.Scroll.Y + w.BoxThickness(1) + point.Y, dest := render.Rect{
} X: rect.X + P.X + w.Scroll.X + w.BoxThickness(1),
Y: rect.Y + P.Y + w.Scroll.Y + w.BoxThickness(1),
W: rect.W,
H: rect.H,
}
if balance.DebugCanvasStrokeColor != render.Invisible { // Cap the render square so it doesn't leave the Canvas and
e.DrawPoint(balance.DebugCanvasStrokeColor, dest) // overlap other UI elements!
} else { if dest.X < P.X {
e.DrawPoint(stroke.Color, dest) // Left edge. TODO: right edge
delta := P.X - dest.X
dest.X = P.X
dest.W -= delta
}
if dest.Y < P.Y {
// Top edge. TODO: bottom edge
delta := P.Y - dest.Y
dest.Y = P.Y
dest.H -= delta
}
if balance.DebugCanvasStrokeColor != render.Invisible {
e.DrawBox(balance.DebugCanvasStrokeColor, dest)
} else {
e.DrawBox(stroke.Color, dest)
}
}
} else {
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)
}
} }
} }
} }