Drag Doodads Onto Levels in Edit Mode

Add the ability to drag and drop Doodads onto the level. The Doodad
buttons on the palette now trigger a Drag/Drop behavior when clicked,
and a "blueprint colored" version of the Doodad follows your cursor,
centered on it.

Actors are assigned a random UUID ID when they are placed into a level.

The Canvas gained a MaskColor property that forces all pixels in the
drawing to render as the same color. This is a visual-only effect, and
is used when dragging Doodads in so they render as "blueprints" instead
of their actual colors until they are dropped.

Fix the chunk bitmap cache system so it saves in the $XDG_CACHE_FOLDER
instead of /tmp and has better names. They go into
`~/.config/doodle/chunks/` and have UUID file names -- but they
disappear quickly! As soon as they are cached into SDL2 they are removed
from disk.

Other changes:

- UI: Add Hovering() method that returns the widgets that are beneath
      a point (your cursor) and those that are not, for easy querying
      for event propagation.
- UI: Add ability to return an ErrStopPropagation to tell the master
      Scene (outside the UI) not to continue sending events to other
      parts of the code, so that you don't draw pixels during a drag
      event.
pull/1/head
Noah 2018-10-20 15:42:49 -07:00
parent 20771fbe13
commit 0044b72943
16 changed files with 602 additions and 188 deletions

View File

@ -15,3 +15,9 @@ var (
// Default size for a new Doodad. // Default size for a new Doodad.
DoodadSize = 100 DoodadSize = 100
) )
// Edit Mode Values
var (
// Number of Doodads per row in the palette.
UIDoodadsPerRow = 2
)

View File

@ -36,4 +36,7 @@ var (
Padding: 4, Padding: 4,
Color: render.Black, Color: render.Black,
} }
// Color for draggable doodad.
DragColor = render.MustHexColor("#0099FF")
) )

18
docs/UI Ideas.md Normal file
View File

@ -0,0 +1,18 @@
# UI Toolkit Ideas
The UI toolkit was loosely inspired by Tk and could copy more of their ideas.
* **Anchor vs. Side:** currently use Anchor to mean Side when packing widgets
into a Frame. It should be renamed to Side, and then Anchor should be how a
widget centers itself in its space, making it easy to have a Center Middle
widget inside a large frame.
* **Hover Background:** currently the Button sets its own color with its own
events, but this could be moved into the BaseWidget. Tk analog is
`activeBackground`
* **Mouse Cursor:** the BaseWidget should provide a way to configure a mouse
cursor when hovering over the widget.
## Label
* **Text Justify:** when multiple lines of text, align them all to the
left, center, or right.

View File

@ -3,14 +3,16 @@ package doodads
import ( import (
"git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/level" "git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/render"
) )
// Doodad is a reusable component for Levels that have scripts and graphics. // Doodad is a reusable component for Levels that have scripts and graphics.
type Doodad struct { type Doodad struct {
level.Base level.Base
Palette *level.Palette `json:"palette"` Filename string `json:"-"` // used internally, not saved in json
Script string `json:"script"` Palette *level.Palette `json:"palette"`
Layers []Layer `json:"layers"` Script string `json:"script"`
Layers []Layer `json:"layers"`
} }
// Layer holds a layer of drawing data for a Doodad. // Layer holds a layer of drawing data for a Doodad.
@ -39,6 +41,20 @@ func New(size int) *Doodad {
} }
} }
// ChunkSize returns the chunk size of the Doodad's first layer.
func (d *Doodad) ChunkSize() int {
return d.Layers[0].Chunker.Size
}
// Rect returns a rect of the ChunkSize for scaling a Canvas widget.
func (d *Doodad) Rect() render.Rect {
var size = d.ChunkSize()
return render.Rect{
W: int32(size),
H: int32(size),
}
}
// Inflate attaches the pixels to their swatches after loading from disk. // Inflate attaches the pixels to their swatches after loading from disk.
func (d *Doodad) Inflate() { func (d *Doodad) Inflate() {
d.Palette.Inflate() d.Palette.Inflate()

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"path/filepath"
) )
// ToJSON serializes the doodad as JSON. // ToJSON serializes the doodad as JSON.
@ -29,6 +30,7 @@ func (d *Doodad) WriteJSON(filename string) error {
return fmt.Errorf("Doodad.WriteJSON: WriteFile error: %s", err) return fmt.Errorf("Doodad.WriteJSON: WriteFile error: %s", err)
} }
d.Filename = filepath.Base(filename)
return nil return nil
} }
@ -49,6 +51,7 @@ func LoadJSON(filename string) (*Doodad, error) {
} }
// Inflate the chunk metadata to map the pixels to their palette indexes. // Inflate the chunk metadata to map the pixels to their palette indexes.
d.Filename = filepath.Base(filename)
d.Inflate() d.Inflate()
return d, err return d, err
} }

View File

@ -2,14 +2,13 @@ package doodle
import ( import (
"fmt" "fmt"
"path/filepath"
"strconv" "strconv"
"git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/enum" "git.kirsle.net/apps/doodle/enum"
"git.kirsle.net/apps/doodle/events" "git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level" "git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui" "git.kirsle.net/apps/doodle/ui"
"git.kirsle.net/apps/doodle/uix" "git.kirsle.net/apps/doodle/uix"
@ -29,8 +28,8 @@ type EditorUI struct {
StatusPaletteText string StatusPaletteText string
StatusFilenameText string StatusFilenameText string
StatusScrollText string StatusScrollText string
selectedSwatch string // name of selected swatch in palette selectedSwatch string // name of selected swatch in palette
selectedDoodad string cursor render.Point // remember the cursor position in Loop
// Widgets // Widgets
Supervisor *ui.Supervisor Supervisor *ui.Supervisor
@ -44,6 +43,9 @@ type EditorUI struct {
PaletteTab *ui.Frame PaletteTab *ui.Frame
DoodadTab *ui.Frame DoodadTab *ui.Frame
// Draggable Doodad canvas.
DraggableActor *DraggableActor
// Palette variables. // Palette variables.
paletteTab string // selected tab, Palette or Doodads paletteTab string // selected tab, Palette or Doodads
} }
@ -144,47 +146,60 @@ func (u *EditorUI) Resized(d *Doodle) {
} }
// Loop to process events and update the UI. // Loop to process events and update the UI.
func (u *EditorUI) Loop(ev *events.State) { func (u *EditorUI) Loop(ev *events.State) error {
u.Supervisor.Loop(ev) u.cursor = render.NewPoint(ev.CursorX.Now, ev.CursorY.Now)
// Loop the UI and see whether we're told to stop event propagation.
var stopPropagation bool
if err := u.Supervisor.Loop(ev); err != nil {
if err == ui.ErrStopPropagation {
stopPropagation = true
} else {
return err
}
}
// Update status bar labels.
{ {
var P = u.Workspace.Point() debugWorldIndex = u.Canvas.WorldIndexAt(u.cursor)
debugWorldIndex = render.NewPoint( u.StatusMouseText = fmt.Sprintf("Rel:(%d,%d) Abs:(%s)",
ev.CursorX.Now-P.X-u.Canvas.Scroll.X,
ev.CursorY.Now-P.Y-u.Canvas.Scroll.Y,
)
u.StatusMouseText = fmt.Sprintf("Mouse: (%d,%d) Px: (%s)",
ev.CursorX.Now, ev.CursorX.Now,
ev.CursorY.Now, ev.CursorY.Now,
debugWorldIndex, debugWorldIndex,
) )
} u.StatusPaletteText = fmt.Sprintf("Swatch: %s",
u.StatusPaletteText = fmt.Sprintf("Swatch: %s", u.Canvas.Palette.ActiveSwatch,
u.Canvas.Palette.ActiveSwatch, )
) u.StatusScrollText = fmt.Sprintf("Scroll: %s Viewport: %s",
u.StatusScrollText = fmt.Sprintf("Scroll: %s Viewport: %s", u.Canvas.Scroll,
u.Canvas.Scroll, u.Canvas.Viewport(),
u.Canvas.Viewport(), )
)
// Statusbar filename label. // Statusbar filename label.
filename := "untitled.map" filename := "untitled.map"
fileType := "Level" fileType := "Level"
if u.Scene.filename != "" { if u.Scene.filename != "" {
filename = u.Scene.filename filename = u.Scene.filename
}
if u.Scene.DrawingType == enum.DoodadDrawing {
fileType = "Doodad"
}
u.StatusFilenameText = fmt.Sprintf("Filename: %s (%s)",
filepath.Base(filename),
fileType,
)
} }
if u.Scene.DrawingType == enum.DoodadDrawing {
fileType = "Doodad"
}
u.StatusFilenameText = fmt.Sprintf("Filename: %s (%s)",
filename,
fileType,
)
// Recompute widgets.
u.MenuBar.Compute(u.d.Engine) u.MenuBar.Compute(u.d.Engine)
u.StatusBar.Compute(u.d.Engine) u.StatusBar.Compute(u.d.Engine)
u.Palette.Compute(u.d.Engine) u.Palette.Compute(u.d.Engine)
u.Canvas.Loop(ev)
// Only forward events to the Canvas if the UI hasn't stopped them.
if !stopPropagation {
u.Canvas.Loop(ev)
}
return nil
} }
// Present the UI to the screen. // Present the UI to the screen.
@ -199,6 +214,17 @@ func (u *EditorUI) Present(e render.Engine) {
u.MenuBar.Present(e, u.MenuBar.Point()) u.MenuBar.Present(e, u.MenuBar.Point())
u.StatusBar.Present(e, u.StatusBar.Point()) u.StatusBar.Present(e, u.StatusBar.Point())
u.Workspace.Present(e, u.Workspace.Point()) u.Workspace.Present(e, u.Workspace.Point())
// Are we dragging a Doodad canvas?
if u.Supervisor.IsDragging() {
if actor := u.DraggableActor; actor != nil {
var size = actor.canvas.Size()
actor.canvas.Present(u.d.Engine, render.NewPoint(
u.cursor.X-(size.W/2),
u.cursor.Y-(size.H/2),
))
}
}
} }
// SetupWorkspace configures the main Workspace frame that takes up the full // SetupWorkspace configures the main Workspace frame that takes up the full
@ -214,9 +240,40 @@ func (u *EditorUI) SetupCanvas(d *Doodle) *uix.Canvas {
drawing := uix.NewCanvas(balance.ChunkSize, true) drawing := uix.NewCanvas(balance.ChunkSize, true)
drawing.Name = "edit-canvas" drawing.Name = "edit-canvas"
drawing.Palette = level.DefaultPalette() drawing.Palette = level.DefaultPalette()
drawing.SetBackground(render.White)
if len(drawing.Palette.Swatches) > 0 { if len(drawing.Palette.Swatches) > 0 {
drawing.SetSwatch(drawing.Palette.Swatches[0]) drawing.SetSwatch(drawing.Palette.Swatches[0])
} }
// Set up the drop handler for draggable doodads.
// NOTE: The drag event begins at editor_ui_doodad.go when configuring the
// Doodad Palette buttons.
drawing.Handle(ui.Drop, func(e render.Point) {
log.Info("Drawing canvas has received a drop!")
var P = ui.AbsolutePosition(drawing)
// Was it an actor from the Doodad Palette?
if actor := u.DraggableActor; actor != nil {
log.Info("Actor is a %s", actor.doodad.Filename)
if u.Scene.Level == nil {
u.d.Flash("Can't drop doodads onto doodad drawings!")
return
}
size := actor.canvas.Size()
u.Scene.Level.Actors.Add(&level.Actor{
// Uncenter the drawing from the cursor.
Point: render.Point{
X: (u.cursor.X - drawing.Scroll.X - (size.W / 2)) - P.X,
Y: (u.cursor.Y - drawing.Scroll.Y - (size.H / 2)) - P.Y,
},
Filename: actor.doodad.Filename,
})
drawing.InstallActors(u.Scene.Level.Actors)
}
})
u.Supervisor.Add(drawing)
return drawing return drawing
} }
@ -395,106 +452,28 @@ func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
// Doodad frame. // Doodad frame.
{ {
u.DoodadTab = ui.NewFrame("Doodad Tab") frame, err := u.setupDoodadFrame(d.Engine, window)
if err != nil {
d.Flash(err.Error())
}
// Even if there was an error (userdir.ListDoodads couldn't read the
// config folder on disk or whatever) the Frame is still valid but
// empty, which is still the intended behavior.
u.DoodadTab = frame
u.DoodadTab.Hide() u.DoodadTab.Hide()
window.Pack(u.DoodadTab, ui.Pack{ window.Pack(u.DoodadTab, ui.Pack{
Anchor: ui.N, Anchor: ui.N,
Fill: true, Fill: true,
}) })
doodadsAvailable, err := userdir.ListDoodads()
if err != nil {
d.Flash("ListDoodads: %s", err)
}
var buttonSize = (paletteWidth - window.BoxThickness(2)) / 2
// Draw the doodad buttons in a grid 2 wide.
var row *ui.Frame
for i, filename := range doodadsAvailable {
si := fmt.Sprintf("%d", i)
if row == nil || i%2 == 0 {
row = ui.NewFrame("Doodad Row " + si)
row.SetBackground(balance.WindowBackground)
u.DoodadTab.Pack(row, ui.Pack{
Anchor: ui.N,
Fill: true,
// Expand: true,
})
}
doodad, err := doodads.LoadJSON(userdir.DoodadPath(filename))
if err != nil {
log.Error(err.Error())
doodad = doodads.New(balance.DoodadSize)
}
can := uix.NewCanvas(int(buttonSize), true)
can.Name = filename
can.LoadDoodad(doodad)
btn := ui.NewRadioButton(filename, &u.selectedDoodad, si, can)
btn.Resize(render.NewRect(
buttonSize-2, // TODO: without the -2 the button border
buttonSize-2, // rests on top of the window border.
))
u.Supervisor.Add(btn)
row.Pack(btn, ui.Pack{
Anchor: ui.W,
})
// Resize the canvas to fill the button interior.
btnSize := btn.Size()
can.Resize(render.NewRect(
btnSize.W-btn.BoxThickness(2),
btnSize.H-btn.BoxThickness(2),
))
btn.Compute(d.Engine)
}
} }
// Color Palette Frame. // Color Palette Frame.
{ u.PaletteTab = u.setupPaletteFrame(window)
u.PaletteTab = ui.NewFrame("Palette Tab") window.Pack(u.PaletteTab, ui.Pack{
u.PaletteTab.SetBackground(balance.WindowBackground) Anchor: ui.N,
window.Pack(u.PaletteTab, ui.Pack{ Fill: true,
Anchor: ui.N, })
Fill: true,
})
// Handler function for the radio buttons being clicked.
onClick := func(p render.Point) {
name := u.selectedSwatch
swatch, ok := u.Canvas.Palette.Get(name)
if !ok {
log.Error("Palette onClick: couldn't get swatch named '%s' from palette", name)
return
}
log.Info("Set swatch: %s", swatch)
u.Canvas.SetSwatch(swatch)
}
// Draw the radio buttons for the palette.
if u.Canvas != nil && u.Canvas.Palette != nil {
for _, swatch := range u.Canvas.Palette.Swatches {
label := ui.NewLabel(ui.Label{
Text: swatch.Name,
Font: balance.StatusFont,
})
label.Font.Color = swatch.Color.Darken(40)
btn := ui.NewRadioButton("palette", &u.selectedSwatch, swatch.Name, label)
btn.Handle(ui.Click, onClick)
u.Supervisor.Add(btn)
u.PaletteTab.Pack(btn, ui.Pack{
Anchor: ui.N,
Fill: true,
PadY: 4,
})
}
}
}
return window return window
} }

115
editor_ui_doodad.go Normal file
View File

@ -0,0 +1,115 @@
package doodle
// XXX REFACTOR XXX
// This function only uses EditorUI and not Doodle and is a candidate for
// refactor into a subpackage if EditorUI itself can ever be decoupled.
import (
"fmt"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
"git.kirsle.net/apps/doodle/uix"
)
// DraggableActor is a Doodad being dragged from the Doodad palette.
type DraggableActor struct {
canvas *uix.Canvas
doodad *doodads.Doodad
}
// setupDoodadFrame configures the Doodad Palette tab for Edit Mode.
// This is a subroutine of editor_ui.go#SetupPalette()
//
// Can return an error if userdir.ListDoodads() returns an error (like directory
// not found), but it will *ALWAYS* return a valid ui.Frame -- it will just be
// empty and uninitialized.
func (u *EditorUI) setupDoodadFrame(e render.Engine, window *ui.Window) (*ui.Frame, error) {
var (
frame = ui.NewFrame("Doodad Tab")
perRow = balance.UIDoodadsPerRow
)
doodadsAvailable, err := userdir.ListDoodads()
if err != nil {
return frame, fmt.Errorf(
"setupDoodadFrame: userdir.ListDoodads: %s",
err,
)
}
var buttonSize = (paletteWidth - window.BoxThickness(2)) / int32(perRow)
// Draw the doodad buttons in a grid `perRow` buttons wide.
var (
row *ui.Frame
rowCount int // for labeling the ui.Frame for each row
)
for i, filename := range doodadsAvailable {
if row == nil || i%perRow == 0 {
rowCount++
row = ui.NewFrame(fmt.Sprintf("Doodad Row %d", rowCount))
row.SetBackground(balance.WindowBackground)
frame.Pack(row, ui.Pack{
Anchor: ui.N,
Fill: true,
})
}
func(filename string) {
doodad, err := doodads.LoadJSON(userdir.DoodadPath(filename))
if err != nil {
log.Error(err.Error())
doodad = doodads.New(balance.DoodadSize)
}
can := uix.NewCanvas(int(buttonSize), true)
can.Name = filename
can.SetBackground(render.White)
can.LoadDoodad(doodad)
btn := ui.NewButton(filename, can)
btn.Resize(render.NewRect(
buttonSize-2, // TODO: without the -2 the button border
buttonSize-2, // rests on top of the window border.
))
row.Pack(btn, ui.Pack{
Anchor: ui.W,
})
// Begin the drag event to grab this Doodad.
// NOTE: The drag target is the EditorUI.Canvas in
// editor_ui.go#SetupCanvas()
btn.Handle(ui.MouseDown, func(e render.Point) {
u.Supervisor.DragStart()
// Create the canvas to render on the mouse cursor.
drawing := uix.NewCanvas(doodad.Layers[0].Chunker.Size, false)
drawing.LoadDoodad(doodad)
drawing.Resize(doodad.Rect())
drawing.SetBackground(render.RGBA(0, 0, 1, 0)) // TODO: invisible becomes white
drawing.MaskColor = balance.DragColor // blueprint effect
u.DraggableActor = &DraggableActor{
canvas: drawing,
doodad: doodad,
}
})
u.Supervisor.Add(btn)
// Resize the canvas to fill the button interior.
btnSize := btn.Size()
can.Resize(render.NewRect(
btnSize.W-btn.BoxThickness(2),
btnSize.H-btn.BoxThickness(2),
),
)
btn.Compute(e)
}(filename)
}
return frame, nil
}

49
editor_ui_palette.go Normal file
View File

@ -0,0 +1,49 @@
package doodle
import (
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
// setupPaletteFrame configures the Color Palette tab for Edit Mode.
// This is a subroutine of editor_ui.go#SetupPalette()
func (u *EditorUI) setupPaletteFrame(window *ui.Window) *ui.Frame {
frame := ui.NewFrame("Palette Tab")
frame.SetBackground(balance.WindowBackground)
// Handler function for the radio buttons being clicked.
onClick := func(p render.Point) {
name := u.selectedSwatch
swatch, ok := u.Canvas.Palette.Get(name)
if !ok {
log.Error("Palette onClick: couldn't get swatch named '%s' from palette", name)
return
}
log.Info("Set swatch: %s", swatch)
u.Canvas.SetSwatch(swatch)
}
// Draw the radio buttons for the palette.
if u.Canvas != nil && u.Canvas.Palette != nil {
for _, swatch := range u.Canvas.Palette.Swatches {
label := ui.NewLabel(ui.Label{
Text: swatch.Name,
Font: balance.StatusFont,
})
label.Font.Color = swatch.Color.Darken(40)
btn := ui.NewRadioButton("palette", &u.selectedSwatch, swatch.Name, label)
btn.Handle(ui.Click, onClick)
u.Supervisor.Add(btn)
frame.Pack(btn, ui.Pack{
Anchor: ui.N,
Fill: true,
PadY: 4,
})
}
}
return frame
}

View File

@ -1,6 +1,9 @@
package level package level
import "git.kirsle.net/apps/doodle/render" import (
"git.kirsle.net/apps/doodle/render"
uuid "github.com/satori/go.uuid"
)
// ActorMap holds the doodad information by their ID in the level data. // ActorMap holds the doodad information by their ID in the level data.
type ActorMap map[string]*Actor type ActorMap map[string]*Actor
@ -12,6 +15,15 @@ func (m ActorMap) Inflate() {
} }
} }
// Add a new Actor to the map. If it doesn't already have an ID it will be
// given a random UUIDv4 ID.
func (m ActorMap) Add(a *Actor) {
if a.id == "" {
a.id = uuid.Must(uuid.NewV4()).String()
}
m[a.id] = a
}
// Actor is an instance of a Doodad in the level. // Actor is an instance of a Doodad in the level.
type Actor struct { type Actor struct {
id string // NOTE: read only, use ID() to access. id string // NOTE: read only, use ID() to access.

View File

@ -8,7 +8,9 @@ import (
"os" "os"
"git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/render"
"github.com/satori/go.uuid"
"golang.org/x/image/bmp" "golang.org/x/image/bmp"
) )
@ -28,8 +30,10 @@ type Chunk struct {
Size int Size int
// Texture cache properties so we don't redraw pixel-by-pixel every frame. // Texture cache properties so we don't redraw pixel-by-pixel every frame.
texture render.Texturer uuid uuid.UUID
dirty bool texture render.Texturer
textureMasked render.Texturer
dirty bool
} }
// JSONChunk holds a lightweight (interface-free) copy of the Chunk for // JSONChunk holds a lightweight (interface-free) copy of the Chunk for
@ -63,26 +67,66 @@ func NewChunk() *Chunk {
// Texture will return a cached texture for the rendering engine for this // Texture will return a cached texture for the rendering engine for this
// chunk's pixel data. If the cache is dirty it will be rebuilt in this func. // chunk's pixel data. If the cache is dirty it will be rebuilt in this func.
func (c *Chunk) Texture(e render.Engine, name string) render.Texturer { func (c *Chunk) Texture(e render.Engine) render.Texturer {
if c.texture == nil || c.dirty { if c.texture == nil || c.dirty {
err := c.ToBitmap("/tmp/" + name + ".bmp") // Generate the normal bitmap and one with a color mask if applicable.
if err != nil { bitmap := c.toBitmap(render.Invisible)
log.Error("Texture: %s", err) defer os.Remove(bitmap)
} tex, err := e.NewBitmap(bitmap)
tex, err := e.NewBitmap("/tmp/" + name + ".bmp")
if err != nil { if err != nil {
log.Error("Texture: %s", err) log.Error("Texture: %s", err)
} }
c.texture = tex c.texture = tex
c.textureMasked = nil // invalidate until next call
c.dirty = false c.dirty = false
} }
return c.texture return c.texture
} }
// TextureMasked returns a cached texture with the ColorMask applied.
func (c *Chunk) TextureMasked(e render.Engine, mask render.Color) render.Texturer {
if c.textureMasked == nil {
// Generate the normal bitmap and one with a color mask if applicable.
bitmap := c.toBitmap(mask)
defer os.Remove(bitmap)
tex, err := e.NewBitmap(bitmap)
if err != nil {
log.Error("Texture: %s", err)
}
c.textureMasked = tex
}
return c.textureMasked
}
// toBitmap puts the texture in a well named bitmap path in the cache folder.
func (c *Chunk) toBitmap(mask render.Color) string {
// Generate a unique filename for this chunk cache.
var filename string
if c.uuid == uuid.Nil {
c.uuid = uuid.Must(uuid.NewV4())
}
filename = c.uuid.String()
if mask != render.Invisible {
filename += fmt.Sprintf("-%02x%02x%02x%02x",
mask.Red, mask.Green, mask.Blue, mask.Alpha,
)
}
// Get the temp bitmap image.
bitmap := userdir.CacheFilename("chunk", filename+".bmp")
err := c.ToBitmap(bitmap, mask)
if err != nil {
log.Error("Texture: %s", err)
}
return bitmap
}
// ToBitmap exports the chunk's pixels as a bitmap image. // ToBitmap exports the chunk's pixels as a bitmap image.
func (c *Chunk) ToBitmap(filename string) error { func (c *Chunk) ToBitmap(filename string, mask render.Color) error {
canvas := c.SizePositive() canvas := c.SizePositive()
imgSize := image.Rectangle{ imgSize := image.Rectangle{
Min: image.Point{}, Min: image.Point{},
@ -117,10 +161,14 @@ func (c *Chunk) ToBitmap(filename string) error {
// Blot all the pixels onto it. // Blot all the pixels onto it.
for px := range c.Iter() { for px := range c.Iter() {
var color = px.Swatch.Color
if mask != render.Invisible {
color = mask
}
img.Set( img.Set(
int(px.X-pointOffset.X), int(px.X-pointOffset.X),
int(px.Y-pointOffset.Y), int(px.Y-pointOffset.Y),
px.Swatch.Color.ToColor(), color.ToColor(),
) )
} }

View File

@ -45,6 +45,7 @@ func New() *Level {
}, },
Chunker: NewChunker(balance.ChunkSize), Chunker: NewChunker(balance.ChunkSize),
Palette: &Palette{}, Palette: &Palette{},
Actors: ActorMap{},
} }
} }

View File

@ -55,6 +55,16 @@ func DoodadPath(filename string) string {
return resolvePath(DoodadDirectory, filename, extDoodad) return resolvePath(DoodadDirectory, filename, extDoodad)
} }
// CacheFilename returns a path to a file in the cache folder. Send in path
// components and not literal slashes, like
// CacheFilename("images", "chunks", "id.bmp")
func CacheFilename(filename ...string) string {
paths := append([]string{CacheDirectory}, filename...)
dir := paths[:len(paths)-1]
configdir.MakePath(filepath.Join(dir...))
return filepath.Join(paths[0], filepath.Join(paths[1:]...))
}
// ListDoodads returns a listing of all available doodads. // ListDoodads returns a listing of all available doodads.
func ListDoodads() ([]string, error) { func ListDoodads() ([]string, error) {
var names []string var names []string

View File

@ -38,6 +38,10 @@ func (r *Renderer) NewBitmap(filename string) (render.Texturer, error) {
} }
defer surface.Free() defer surface.Free()
// TODO: chroma key color hardcoded to white here
key := sdl.MapRGB(surface.Format, 255, 255, 255)
surface.SetColorKey(true, key)
tex, err := r.renderer.CreateTextureFromSurface(surface) tex, err := r.renderer.CreateTextureFromSurface(surface)
if err != nil { if err != nil {
return nil, fmt.Errorf("NewBitmap: create texture: %s", err) return nil, fmt.Errorf("NewBitmap: create texture: %s", err)

28
ui/dragdrop.go Normal file
View File

@ -0,0 +1,28 @@
package ui
// DragDrop is a state machine to manage draggable UI components.
type DragDrop struct {
isDragging bool
}
// NewDragDrop initializes the DragDrop struct. Normally your Supervisor
// will manage the drag/drop object, but you can use your own if you don't
// use a Supervisor.
func NewDragDrop() *DragDrop {
return &DragDrop{}
}
// IsDragging returns whether the drag state is active.
func (dd *DragDrop) IsDragging() bool {
return dd.isDragging
}
// Start the drag state.
func (dd *DragDrop) Start() {
dd.isDragging = true
}
// Stop dragging.
func (dd *DragDrop) Stop() {
dd.isDragging = false
}

View File

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"errors"
"sync" "sync"
"git.kirsle.net/apps/doodle/events" "git.kirsle.net/apps/doodle/events"
@ -21,6 +22,7 @@ const (
KeyDown KeyDown
KeyUp KeyUp
KeyPress KeyPress
Drop
) )
// Supervisor keeps track of widgets of interest to notify them about // Supervisor keeps track of widgets of interest to notify them about
@ -28,22 +30,56 @@ const (
// vicinity. // vicinity.
type Supervisor struct { type Supervisor struct {
lock sync.RWMutex lock sync.RWMutex
widgets []Widget serial int // ID number of each widget added in order
hovering map[int]interface{} widgets map[int]WidgetSlot // map of widget ID to WidgetSlot
clicked map[int]interface{} hovering map[int]interface{} // map of widgets under the cursor
clicked map[int]interface{} // map of widgets being clicked
dd *DragDrop
}
// WidgetSlot holds a widget with a unique ID number in a sorted list.
type WidgetSlot struct {
id int
widget Widget
} }
// NewSupervisor creates a supervisor. // NewSupervisor creates a supervisor.
func NewSupervisor() *Supervisor { func NewSupervisor() *Supervisor {
return &Supervisor{ return &Supervisor{
widgets: []Widget{}, widgets: map[int]WidgetSlot{},
hovering: map[int]interface{}{}, hovering: map[int]interface{}{},
clicked: map[int]interface{}{}, clicked: map[int]interface{}{},
dd: NewDragDrop(),
} }
} }
// DragStart sets the drag state.
func (s *Supervisor) DragStart() {
s.dd.Start()
}
// DragStop stops the drag state.
func (s *Supervisor) DragStop() {
s.dd.Stop()
}
// IsDragging returns whether the drag state is enabled.
func (s *Supervisor) IsDragging() bool {
return s.dd.IsDragging()
}
// Error messages that may be returned by Supervisor.Loop()
var (
// The caller should STOP forwarding any mouse or keyboard events to any
// other handles for the remainder of this tick.
ErrStopPropagation = errors.New("stop all event propagation")
)
// Loop to check events and pass them to managed widgets. // Loop to check events and pass them to managed widgets.
func (s *Supervisor) Loop(ev *events.State) { //
// Useful errors returned by this may be:
// - ErrStopPropagation
func (s *Supervisor) Loop(ev *events.State) error {
var ( var (
XY = render.Point{ XY = render.Point{
X: ev.CursorX.Now, X: ev.CursorX.Now,
@ -52,14 +88,84 @@ func (s *Supervisor) Loop(ev *events.State) {
) )
// See if we are hovering over any widgets. // See if we are hovering over any widgets.
for id, w := range s.widgets { hovering, outside := s.Hovering(XY)
// If we are dragging something around, do not trigger any mouse events
// to other widgets but DO notify any widget we dropped on top of!
if s.dd.IsDragging() {
if !ev.Button1.Now && !ev.Button2.Now {
// The mouse has been released. TODO: make mouse button important?
log.Info("Supervisor: STOP DRAGGING")
for _, child := range hovering {
child.widget.Event(Drop, XY)
}
s.DragStop()
}
return ErrStopPropagation
}
for _, child := range hovering {
var (
id = child.id
w = child.widget
)
if w.Hidden() { if w.Hidden() {
// TODO: somehow the Supervisor wasn't triggering hidden widgets // TODO: somehow the Supervisor wasn't triggering hidden widgets
// anyway, but I don't know why. Adding this check for safety. // anyway, but I don't know why. Adding this check for safety.
continue continue
} }
// Cursor has intersected the widget.
if _, ok := s.hovering[id]; !ok {
w.Event(MouseOver, XY)
s.hovering[id] = nil
}
_, isClicked := s.clicked[id]
if ev.Button1.Now {
if !isClicked {
w.Event(MouseDown, XY)
s.clicked[id] = nil
}
} else if isClicked {
w.Event(MouseUp, XY)
w.Event(Click, XY)
delete(s.clicked, id)
}
}
for _, child := range outside {
var ( var (
id = child.id
w = child.widget
)
// Cursor is not intersecting the widget.
if _, ok := s.hovering[id]; ok {
w.Event(MouseOut, XY)
delete(s.hovering, id)
}
if _, ok := s.clicked[id]; ok {
w.Event(MouseUp, XY)
delete(s.clicked, id)
}
}
return nil
}
// Hovering returns all of the widgets managed by Supervisor that are under
// the mouse cursor. Returns the set of widgets below the cursor and the set
// of widgets not below the cursor.
func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSlot) {
var XY = cursor // for shorthand
hovering = []WidgetSlot{}
outside = []WidgetSlot{}
// Check all the widgets under our care.
for child := range s.Widgets() {
var (
w = child.widget
P = w.Point() P = w.Point()
S = w.Size() S = w.Size()
P2 = render.Point{ P2 = render.Point{
@ -69,36 +175,29 @@ func (s *Supervisor) Loop(ev *events.State) {
) )
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 has intersected the widget. // Cursor intersects the widget.
if _, ok := s.hovering[id]; !ok { hovering = append(hovering, child)
w.Event(MouseOver, XY)
s.hovering[id] = nil
}
_, isClicked := s.clicked[id]
if ev.Button1.Now {
if !isClicked {
w.Event(MouseDown, XY)
s.clicked[id] = nil
}
} else if isClicked {
w.Event(MouseUp, XY)
w.Event(Click, XY)
delete(s.clicked, id)
}
} else { } else {
// Cursor is not intersecting the widget. outside = append(outside, child)
if _, ok := s.hovering[id]; ok {
w.Event(MouseOut, XY)
delete(s.hovering, id)
}
if _, ok := s.clicked[id]; ok {
w.Event(MouseUp, XY)
delete(s.clicked, id)
}
} }
} }
return hovering, outside
}
// Widgets returns a channel of widgets managed by the supervisor in the order
// they were added.
func (s *Supervisor) Widgets() <-chan WidgetSlot {
pipe := make(chan WidgetSlot)
go func() {
for i := 0; i < s.serial; i++ {
if w, ok := s.widgets[i]; ok {
pipe <- w
}
}
close(pipe)
}()
return pipe
} }
// Present all widgets managed by the supervisor. // Present all widgets managed by the supervisor.
@ -106,7 +205,8 @@ func (s *Supervisor) Present(e render.Engine) {
s.lock.RLock() s.lock.RLock()
defer s.lock.RUnlock() defer s.lock.RUnlock()
for _, w := range s.widgets { for child := range s.Widgets() {
var w = child.widget
w.Present(e, w.Point()) w.Present(e, w.Point())
} }
} }
@ -114,6 +214,10 @@ func (s *Supervisor) Present(e render.Engine) {
// Add a widget to be supervised. // Add a widget to be supervised.
func (s *Supervisor) Add(w Widget) { func (s *Supervisor) Add(w Widget) {
s.lock.Lock() s.lock.Lock()
s.widgets = append(s.widgets, w) s.widgets[s.serial] = WidgetSlot{
id: s.serial,
widget: w,
}
s.serial++
s.lock.Unlock() s.lock.Unlock()
} }

View File

@ -18,9 +18,16 @@ type Canvas struct {
ui.Frame ui.Frame
Palette *level.Palette Palette *level.Palette
// Set to true to allow clicking to edit this canvas. // Editable and Scrollable go hand in hand and, if you initialize a
Editable bool // NewCanvas() with editable=true, they are both enabled.
Scrollable bool Editable bool // Clicking will edit pixels of this canvas.
Scrollable bool // Cursor keys will scroll the viewport of this canvas.
// 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 does work as normal. Set to render.Invisible (zero value)
// to remove the mask.
MaskColor render.Color
// Underlying chunk data for the drawing. // Underlying chunk data for the drawing.
chunks *level.Chunker chunks *level.Chunker
@ -111,6 +118,9 @@ func (w *Canvas) InstallActors(actors level.ActorMap) error {
can := NewCanvas(int(size), false) can := NewCanvas(int(size), false)
can.Name = id can.Name = id
can.actor = actor can.actor = actor
// TODO: if the Background is render.Invisible it gets defaulted to
// White somewhere and the Doodad masks the level drawing behind it.
can.SetBackground(render.RGBA(0, 0, 1, 0))
can.LoadDoodad(doodad) can.LoadDoodad(doodad)
can.Resize(render.NewRect(size, size)) can.Resize(render.NewRect(size, size))
w.actors = append(w.actors, &Actor{ w.actors = append(w.actors, &Actor{
@ -128,8 +138,6 @@ func (w *Canvas) SetSwatch(s *level.Swatch) {
// setup common configs between both initializers of the canvas. // setup common configs between both initializers of the canvas.
func (w *Canvas) setup() { func (w *Canvas) setup() {
w.SetBackground(render.White)
// XXX: Debug code. // XXX: Debug code.
if balance.DebugCanvasBorder != render.Invisible { if balance.DebugCanvasBorder != render.Invisible {
w.Configure(ui.Config{ w.Configure(ui.Config{
@ -138,13 +146,6 @@ func (w *Canvas) setup() {
BorderStyle: ui.BorderSolid, BorderStyle: ui.BorderSolid,
}) })
} }
w.Handle(ui.MouseOver, func(p render.Point) {
w.SetBackground(render.Yellow)
})
w.Handle(ui.MouseOut, func(p render.Point) {
w.SetBackground(render.SkyBlue)
})
} }
// Loop is called on the scene's event loop to handle mouse interaction with // Loop is called on the scene's event loop to handle mouse interaction with
@ -258,6 +259,18 @@ func (w *Canvas) ViewportRelative() render.Rect {
} }
} }
// WorldIndexAt returns the World Index that corresponds to a Screen Pixel
// on the screen. If the screen pixel is the mouse coordinate (relative to
// the application window) this will return the World Index of the pixel below
// the mouse cursor.
func (w *Canvas) WorldIndexAt(screenPixel render.Point) render.Point {
var P = ui.AbsolutePosition(w)
return render.Point{
X: screenPixel.X - P.X - w.Scroll.X,
Y: screenPixel.Y - P.Y - w.Scroll.Y,
}
}
// Chunker returns the underlying Chunker object. // Chunker returns the underlying Chunker object.
func (w *Canvas) Chunker() *level.Chunker { func (w *Canvas) Chunker() *level.Chunker {
return w.chunks return w.chunks
@ -297,7 +310,12 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
// Get the chunks in the viewport and cache their textures. // Get the chunks in the viewport and cache their textures.
for coord := range w.chunks.IterViewportChunks(Viewport) { for coord := range w.chunks.IterViewportChunks(Viewport) {
if chunk, ok := w.chunks.GetChunk(coord); ok { if chunk, ok := w.chunks.GetChunk(coord); ok {
tex := chunk.Texture(e, w.Name+coord.String()) var tex render.Texturer
if w.MaskColor != render.Invisible {
tex = chunk.TextureMasked(e, w.MaskColor)
} else {
tex = chunk.Texture(e)
}
src := render.Rect{ src := render.Rect{
W: tex.Size().W, W: tex.Size().W,
H: tex.Size().H, H: tex.Size().H,