doodle/pkg/uix/canvas_editable.go
Noah Petherbridge af6b8625d6 Flood Tool, Survival Mode for Azulian Tag
New features:
* Flood Tool for the editor. It replaces pixels of one color with another,
  contiguously. Has limits on how far from the original pixel it will color,
  to avoid infinite loops in case the user clicked on wide open void. The
  limit when clicking an existing color is 1200px or only a 600px limit if
  clicking into the void.
* Cheat code: 'master key' to play locked Story Mode levels.

Level GameRules feature added:
* A new tab in the Level Properties dialog
* Difficulty has been moved to this tab
* Survival Mode: for silver high score, longest time alive is better than
  fastest time, for Azulian Tag maps. Gold high score is still based on
  fastest time - find the hidden level exit without dying!

Tweaks to the Azulians' jump heights:
* Blue Azulian:  12 -> 14
* Red Azulian:   14 -> 18
* White Azulian: 16 -> 20

Bugs fixed:
* When editing your Palette to rename a color or add a new color, it wasn't
  possible to draw with that color until the editor was completely unloaded
  and reloaded; this is now fixed.
* Minor bugfix in Difficulty.String() for Peaceful (-1) difficulty to avoid
  a negative array index.
* Try and prevent user giving the same name to multiple swatches on their
  palette. Replacing the whole palette can let duplication through still.
2022-03-26 13:55:06 -07:00

576 lines
16 KiB
Go

package uix
import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
)
// Modified returns whether the canvas has been modified since it was last
// loaded. Methods like Load and LoadFile will set modified to false, and
// commitStroke sets it to true.
func (w *Canvas) Modified() bool {
return w.modified
}
// SetModified sets the modified bit on the canvas.
func (w *Canvas) SetModified(v bool) {
w.modified = v
}
// 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
}
// Zoom the stroke coordinates (this modifies the pointer).
// Note: all the points on the stroke were mouse cursor coordinates on the screen.
w.currentStroke = w.ZoomStroke(w.currentStroke)
// Mark the canvas as modified.
w.modified = true
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 addHistory {
w.strokeToHistory(w.currentStroke)
}
w.RemoveStroke(w.currentStroke)
w.currentStroke = nil
w.lastPixel = nil
}
// Add a recently drawn stroke to the UndoHistory.
func (w *Canvas) strokeToHistory(stroke *drawtool.Stroke) {
if w.level != nil {
w.level.UndoHistory.AddStroke(stroke)
} else if w.doodad != nil {
if w.doodad.UndoHistory == nil {
// HACK: if UndoHistory was not initialized properly.
w.doodad.UndoHistory = drawtool.NewHistory(balance.UndoHistory)
}
w.doodad.UndoHistory.AddStroke(stroke)
}
}
// loopEditable handles the Loop() part for editable canvases.
func (w *Canvas) loopEditable(ev *event.State) error {
// Get the absolute position of the canvas on screen to accurately match
// it up to mouse clicks.
var (
P = ui.AbsolutePosition(w)
cursor = render.Point{
X: ev.CursorX - P.X - w.Scroll.X,
Y: ev.CursorY - P.Y - w.Scroll.Y,
}
)
// 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 {
case drawtool.PanTool:
// Pan tool = click to pan the level.
if keybind.LeftClick(ev) || keybind.MiddleClick(ev) {
if !w.scrollDragging {
w.scrollDragging = true
w.scrollStartAt = shmem.Cursor
w.scrollWasAt = w.Scroll
} else {
delta := shmem.Cursor.Compare(w.scrollStartAt)
w.Scroll = w.scrollWasAt
w.Scroll.Subtract(delta)
// TODO: if I don't call this, the user is able to (temporarily!)
// pan outside the level boundaries before it snaps-back when they
// release. But the normal middle-click to pan code doesn't let
// them do this.. investigate why later.
w.loopConstrainScroll()
}
} else {
if w.scrollDragging {
w.scrollDragging = false
}
}
case drawtool.PencilTool:
// If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil {
return nil
}
// Clicking? Log all the pixels while doing so.
if keybind.LeftClick(ev) {
// 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.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.AddStroke(w.currentStroke)
}
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 {
// Draw the pixels in between.
if lastPixel != nil && lastPixel != pixel {
for point := range render.IterLine(lastPixel.Point(), pixel.Point()) {
w.currentStroke.AddPoint(point)
}
}
w.lastPixel = pixel
// Save the pixel in the current stroke.
w.currentStroke.AddPoint(render.Point{
X: cursor.X,
Y: cursor.Y,
})
}
} else {
w.commitStroke(w.Tool, true)
}
case drawtool.LineTool:
// If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil {
return nil
}
// Clicking? Log all the pixels while doing so.
if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
w.AddStroke(w.currentStroke)
}
w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y)
} else {
w.commitStroke(w.Tool, true)
}
case drawtool.RectTool:
// If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil {
return nil
}
// Clicking? Log all the pixels while doing so.
if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
w.AddStroke(w.currentStroke)
}
w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y)
} else {
w.commitStroke(w.Tool, true)
}
case drawtool.EllipseTool:
if w.Palette.ActiveSwatch == nil {
return nil
}
if keybind.LeftClick(ev) {
if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
w.currentStroke.Thickness = w.BrushSize
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.currentStroke.PointA = render.NewPoint(cursor.X, cursor.Y)
w.AddStroke(w.currentStroke)
}
w.currentStroke.PointB = render.NewPoint(cursor.X, cursor.Y)
} else {
w.commitStroke(w.Tool, true)
}
case drawtool.TextTool:
// The Text Tool popup should initialize this for us, if somehow not
// initialized skip this tool processing.
if w.Palette.ActiveSwatch == nil || drawtool.TT.IsZero() {
return nil
}
// Do we need to create the Label?
if drawtool.TT.Label == nil {
drawtool.TT.Label = ui.NewLabel(ui.Label{
Text: drawtool.TT.Message,
Font: render.Text{
FontFilename: drawtool.TT.Font,
Size: drawtool.TT.Size,
Color: w.Palette.ActiveSwatch.Color,
},
})
}
// Do we need to update the color of the label?
if drawtool.TT.Label.Font.Color != w.Palette.ActiveSwatch.Color {
drawtool.TT.Label.Font.Color = w.Palette.ActiveSwatch.Color
}
// NOTE: Canvas.presentStrokes() will handle drawing the font preview
// at the cursor location while the TextTool is active.
// On mouse click, commit the text to the drawing.
if keybind.LeftClick(ev) {
if stroke, err := drawtool.TT.ToStroke(shmem.CurrentRenderEngine, w.Palette.ActiveSwatch.Color, cursor); err != nil {
shmem.FlashError("Text Tool error: %s", err)
return nil
} else {
w.currentStroke = stroke
w.currentStroke.ExtraData = w.Palette.ActiveSwatch
w.commitStroke(drawtool.PencilTool, true)
}
keybind.ClearLeftClick(ev)
}
case drawtool.FloodTool:
if w.Palette.ActiveSwatch == nil {
return nil
}
// Click to activate.
if keybind.LeftClick(ev) {
var (
chunker = w.Chunker()
stroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color)
)
// Set some max boundaries to prevent runaway infinite loops, e.g. if user
// clicked the wide open void the flood fill would never finish!
limit := balance.FloodToolLimit
// Get the original color at this location.
// Error cases can include: no chunk at this spot, or no pixel at this spot.
// Treat these as just a null color and proceed anyway, user should be able
// to flood fill blank areas of their level.
baseColor, err := chunker.Get(cursor)
if err != nil {
limit = balance.FloodToolVoidLimit
log.Warn("FloodTool: couldn't get base color at %s: %s (got %s)", cursor, err)
}
// If no change, do nothing.
if baseColor == w.Palette.ActiveSwatch {
break
}
// The flood fill algorithm.
queue := []render.Point{cursor}
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
colorAt, _ := chunker.Get(node)
if colorAt != baseColor {
continue
}
// For Undo history, store the original color at this point.
if colorAt != nil {
stroke.OriginalPoints[node] = colorAt
}
// Add the neighboring pixels.
for _, neighbor := range []render.Point{
{X: node.X - 1, Y: node.Y},
{X: node.X + 1, Y: node.Y},
{X: node.X, Y: node.Y - 1},
{X: node.X, Y: node.Y + 1},
} {
// Only if not too far from the origin!
if render.AbsInt(neighbor.X-cursor.X) <= limit && render.AbsInt(neighbor.Y-cursor.Y) <= limit {
queue = append(queue, neighbor)
}
}
stroke.AddPoint(node)
err = chunker.Set(node, w.Palette.ActiveSwatch)
if err != nil {
log.Error("FloodTool: error setting %s to %s: %s", node, w.Palette.ActiveSwatch, err)
}
}
w.strokeToHistory(stroke)
keybind.ClearLeftClick(ev)
}
case drawtool.EraserTool:
// Clicking? Log all the pixels while doing so.
if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil {
// The color is white, will look like white-out that covers the
// wallpaper during the stroke.
w.currentStroke = drawtool.NewStroke(drawtool.Eraser, render.White)
w.currentStroke.Thickness = w.BrushSize
w.AddStroke(w.currentStroke)
}
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.Point(), pixel.Point()) {
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:
// See if any of the actors are below the mouse cursor.
var WP = w.WorldIndexAt(cursor)
// log.Debug("ActorTool, cursor=%s WP=%s zoom=%d P=%s", cursor, WP, w.Zoom, ui.AbsolutePosition(w))
var deleteActors = []*level.Actor{}
for _, actor := range w.actors {
// Compute the bounding box on screen where this doodad
// visually appears.
var scrollBias = render.Point{
X: w.Scroll.X,
Y: w.Scroll.Y,
}
if w.Zoom != 0 {
scrollBias.X = w.ZoomDivide(scrollBias.X)
scrollBias.Y = w.ZoomDivide(scrollBias.Y)
}
box := render.Rect{
X: actor.Actor.Point.X - scrollBias.X - w.ZoomDivide(P.X),
Y: actor.Actor.Point.Y - scrollBias.Y - w.ZoomDivide(P.Y),
W: actor.Canvas.Size().W,
H: actor.Canvas.Size().H,
}
if WP.Inside(box) {
actor.Canvas.Configure(ui.Config{
BorderSize: 1,
BorderColor: render.RGBA(255, 153, 0, 255),
BorderStyle: ui.BorderSolid,
Background: render.White, // TODO: cuz the border draws a bgcolor
})
// Check for a mouse down event to begin dragging this
// canvas around.
if keybind.LeftClick(ev) {
// Pop this canvas out for the drag/drop.
if w.OnDragStart != nil {
deleteActors = append(deleteActors, actor.Actor)
w.OnDragStart(actor.Actor)
}
break
} else if ev.Button3 {
// Right click to delete an actor.
deleteActors = append(deleteActors, actor.Actor)
}
} else {
actor.Canvas.SetBorderSize(0)
actor.Canvas.SetBackground(render.RGBA(0, 0, 1, 0)) // TODO
}
}
// Change in actor count?
if len(deleteActors) > 0 && w.OnDeleteActors != nil {
w.OnDeleteActors(deleteActors)
}
case drawtool.LinkTool:
// See if any of the actors are below the mouse cursor.
var WP = w.WorldIndexAt(cursor)
for _, actor := range w.actors {
// Permanently color the actor if it's the current subject of the
// Link Tool (after 1st click, until 2nd click of other actor)
if w.linkFirst == actor {
actor.Canvas.Configure(ui.Config{
Background: render.RGBA(255, 153, 255, 153),
})
continue
}
// Compute the bounding box on screen where this doodad
// visually appears.
var scrollBias = render.Point{
X: w.Scroll.X,
Y: w.Scroll.Y,
}
if w.Zoom != 0 {
scrollBias.X = w.ZoomDivide(scrollBias.X)
scrollBias.Y = w.ZoomDivide(scrollBias.Y)
}
box := render.Rect{
X: actor.Actor.Point.X - scrollBias.X - w.ZoomDivide(P.X),
Y: actor.Actor.Point.Y - scrollBias.Y - w.ZoomDivide(P.Y),
W: actor.Canvas.Size().W,
H: actor.Canvas.Size().H,
}
if WP.Inside(box) {
actor.Canvas.Configure(ui.Config{
BorderSize: 1,
BorderColor: render.RGBA(255, 153, 255, 255),
BorderStyle: ui.BorderSolid,
Background: render.White, // TODO: cuz the border draws a bgcolor
})
// Click handler to start linking this actor.
if keybind.LeftClick(ev) {
if err := w.LinkAdd(actor); err != nil {
return err
}
// TODO: reset the Button1 state so we don't finish a
// link and then LinkAdd the clicked doodad immediately
// (causing link chaining)
keybind.ClearLeftClick(ev)
break
}
} else {
actor.Canvas.SetBorderSize(0)
actor.Canvas.SetBackground(render.RGBA(0, 0, 1, 0)) // TODO
}
}
}
return nil
}