Draw Actors Embedded in Levels in Edit Mode

Add the JSON format for embedding Actors (Doodad instances) inside of a
Level. I made a test map that manually inserted a couple of actors.

Actors are given to the Canvas responsible for the Level via the
function `InstallActors()`. So it means you'll call LoadLevel and then
InstallActors to hook everything up.

The Canvas creates sub-Canvas widgets from each Actor.

After drawing the main level geometry from the Canvas.Chunker, it calls
the drawActors() function which does the same but for Actors.

Levels keep a global map of all Actors that exist. For any Actors that
are visible within the Viewport, their sub-Canvas widgets are presented
appropriately on top of the parent Canvas. In case their sub-Canvas
overlaps the parent's boundaries, their sub-Canvas is resized and moved
appropriately.

- Allow the MainWindow to be resized at run time, and the UI
  recalculates its sizing and position.
- Made the in-game Shell properties editable via environment variables.
  The kirsle.env file sets a blue and pink color scheme.
- Begin the ground work for Levels and Doodads to embed files inside
  their data via the level.FileSystem type.
- UI: Labels can now contain line break characters. It will
  appropriately render multiple lines of render.Text and take into
  account the proper BoxSize to contain them all.
- Add environment variable DOODLE_DEBUG_ALL=true that will turn on ALL
  debug overlay and visualization options.
- Add debug overlay to "tag" each Canvas widget with some of its
  details, like its Name and World Position. Can be enabled with the
  environment variable DEBUG_CANVAS_LABEL=true
- Improved the FPS debug overlay to show in labeled columns and multiple
  colors, with easy ability to add new data points to it.
This commit is contained in:
Noah 2018-10-19 13:31:58 -07:00
parent 1c5a0842e4
commit 20771fbe13
29 changed files with 906 additions and 370 deletions

View File

@ -21,12 +21,16 @@ like `#FF00FF99` for 153 ($99) on the alpha channel.
* `D_SHELL_FS=16`: font size for both the shell and on-screen flashed
messages.
* Debug Colors and Hitboxes (default invisible=off):
* `DOODLE_DEBUG_ALL=false`: turn on all debug colors and hitboxes to their
default colors and settings.
* `DEBUG_CHUNK_COLOR=#FFFFFF`: background color when caching a
chunk to bitmap. Helps visualize where the chunks and caching
are happening.
* `DEBUG_CANVAS_BORDER`: draw a border color around every uix.Canvas
widget. This effectively draws the bounds of every Doodad drawn on top
of a level or inside a button and the bounds of the level space itself.
* `DEBUG_CANVAS_LABEL=false`: draw a label in the corner of every Canvas
with details about the Canvas.
* Tuning constants (may not be available in production builds):
* `D_SCROLL_SPEED=8`: Canvas scroll speed when using the keyboard arrows
in the Editor Mode, in pixels per tick.

View File

@ -1,9 +1,9 @@
package balance
import (
"fmt"
"os"
"strconv"
"strings"
"git.kirsle.net/apps/doodle/render"
)
@ -15,13 +15,21 @@ var (
* Visualizers *
***************/
// Debug overlay (FPS etc.) settings.
DebugFontFilename = "./fonts/DejaVuSans-Bold.ttf"
DebugFontSize = 15
DebugLabelColor = render.MustHexColor("#FF9900")
DebugValueColor = render.MustHexColor("#00CCFF")
DebugStrokeDarken int32 = 80
// Background color to use when exporting a drawing Chunk as a bitmap image
// on disk. Default is white. Setting this to translucent yellow is a great
// way to visualize the chunks loaded from cache on your screen.
DebugChunkBitmapBackground = render.White // XXX: export $DEBUG_CHUNK_COLOR
// Put a border around all Canvas widgets.
DebugCanvasBorder = render.Red
DebugCanvasBorder = render.Invisible
DebugCanvasLabel = false // Tag the canvas with a label.
)
func init() {
@ -45,24 +53,33 @@ func init() {
// Visualizers
"DEBUG_CHUNK_COLOR": &DebugChunkBitmapBackground,
"DEBUG_CANVAS_BORDER": &DebugCanvasBorder,
"DEBUG_CANVAS_LABEL": &DebugCanvasLabel,
}
for name, value := range config {
switch v := value.(type) {
case *int:
*v = IntEnv(name, *(v))
case *bool:
*v = BoolEnv(name, *(v))
case *int32:
*v = int32(IntEnv(name, int(*(v))))
case *render.Color:
*v = ColorEnv(name, *(v))
}
}
// Debug all?
if BoolEnv("DOODLE_DEBUG_ALL", false) {
DebugChunkBitmapBackground = render.RGBA(255, 255, 0, 128)
DebugCanvasBorder = render.Red
DebugCanvasLabel = true
}
}
// ColorEnv gets a color value from environment variable or returns a default.
// This will panic if the color is not valid, so only do this on startup time.
func ColorEnv(name string, v render.Color) render.Color {
if color := os.Getenv(name); color != "" {
fmt.Printf("set %s to %s\n", name, color)
return render.MustHexColor(color)
}
return v
@ -79,3 +96,16 @@ func IntEnv(name string, v int) int {
}
return v
}
// BoolEnv gets a bool from the environment with a default.
func BoolEnv(name string, v bool) bool {
if env := os.Getenv(name); env != "" {
switch strings.ToLower(env) {
case "true", "t", "1", "on", "yes", "y":
return true
case "false", "f", "0", "off", "no", "n":
return false
}
}
return v
}

View File

@ -6,13 +6,13 @@ import (
// Shell related variables.
var (
// TODO: why not renders transparent
ShellFontFilename = "./fonts/DejaVuSansMono.ttf"
ShellBackgroundColor = render.RGBA(0, 20, 40, 200)
ShellForegroundColor = render.RGBA(0, 153, 255, 255)
ShellPromptColor = render.White
ShellPadding int32 = 8
ShellFontSize = 16
ShellFontSizeSmall = 10
ShellCursorBlinkRate uint64 = 20
ShellHistoryLineCount = 8

132
config.go
View File

@ -2,106 +2,16 @@ package doodle
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"git.kirsle.net/apps/doodle/render"
"github.com/kirsle/configdir"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// Configuration constants.
var (
DebugTextPadding int32 = 8
DebugTextSize = 24
DebugTextColor = render.SkyBlue
DebugTextStroke = render.Grey
DebugTextShadow = render.Black
)
// Profile Directory settings.
var (
ConfigDirectoryName = "doodle"
ProfileDirectory string
LevelDirectory string
DoodadDirectory string
CacheDirectory string
FontDirectory string
// Regexp to match simple filenames for maps and doodads.
reSimpleFilename = regexp.MustCompile(`^([A-Za-z0-9-_.,+ '"\[\](){}]+)$`)
)
// File extensions
const (
extLevel = ".level"
extDoodad = ".doodad"
)
func init() {
// Profile directory contains the user's levels and doodads.
ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName)
LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels")
DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads")
// Cache directory to extract font files to.
CacheDirectory = configdir.LocalCache(ConfigDirectoryName)
FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts")
// Ensure all the directories exist.
configdir.MakePath(LevelDirectory)
configdir.MakePath(DoodadDirectory)
configdir.MakePath(FontDirectory)
}
// LevelPath will turn a "simple" filename into an absolute path in the user's
// local levels folder. If the filename already contains slashes, it is returned
// as-is as an absolute or relative path.
func LevelPath(filename string) string {
return resolvePath(LevelDirectory, filename, extLevel)
}
// DoodadPath is like LevelPath but for Doodad files.
func DoodadPath(filename string) string {
return resolvePath(DoodadDirectory, filename, extDoodad)
}
// ListDoodads returns a listing of all available doodads.
func ListDoodads() ([]string, error) {
var names []string
files, err := ioutil.ReadDir(DoodadDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if strings.HasSuffix(strings.ToLower(name), extDoodad) {
names = append(names, name)
}
}
return names, nil
}
// resolvePath is the inner logic for LevelPath and DoodadPath.
func resolvePath(directory, filename, extension string) string {
if strings.Contains(filename, "/") {
return filename
}
// Attach the file extension?
if strings.ToLower(filepath.Ext(filename)) != extension {
filename += extension
}
return filepath.Join(directory, filename)
}
var reSimpleFilename = regexp.MustCompile(`^([A-Za-z0-9-_.,+ '"\[\](){}]+)$`)
/*
EditFile opens a drawing file (Level or Doodad) in the EditorScene.
@ -123,7 +33,7 @@ func (d *Doodle) EditFile(filename string) error {
if m := reSimpleFilename.FindStringSubmatch(filename); len(m) > 0 {
log.Debug("EditFile: simple filename %s", filename)
extension := strings.ToLower(filepath.Ext(filename))
if foundFilename := d.ResolvePath(filename, extension, false); foundFilename != "" {
if foundFilename := userdir.ResolvePath(filename, extension, false); foundFilename != "" {
log.Info("EditFile: resolved name '%s' to path %s", filename, foundFilename)
absPath = foundFilename
} else {
@ -141,39 +51,3 @@ func (d *Doodle) EditFile(filename string) error {
return nil
}
// ResolvePath takes an ambiguous simple filename and searches for a Level or
// Doodad that matches. Returns a blank string if no files found.
//
// Pass a true value for `one` if you are intending to create the file. It will
// only test one filepath and return the first one, regardless if the file
// existed. So the filename should have a ".level" or ".doodad" extension and
// then this path will resolve the ProfileDirectory of the file.
func (d *Doodle) ResolvePath(filename, extension string, one bool) string {
// If the filename exists outright, return it.
if _, err := os.Stat(filename); !os.IsNotExist(err) {
return filename
}
var paths []string
if extension == extLevel {
paths = append(paths, filepath.Join(LevelDirectory, filename))
} else if extension == extDoodad {
paths = append(paths, filepath.Join(DoodadDirectory, filename))
} else {
paths = append(paths,
filepath.Join(LevelDirectory, filename+".level"),
filepath.Join(DoodadDirectory, filename+".doodad"),
)
}
for _, test := range paths {
log.Debug("findFilename: try to find '%s' as %s", filename, test)
if _, err := os.Stat(test); os.IsNotExist(err) {
continue
}
return test
}
return ""
}

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/enum"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/render"
"github.com/kirsle/golog"
)
@ -28,11 +29,15 @@ type Doodle struct {
Engine render.Engine
engineReady bool
// Easy access to the event state, for the debug overlay to use.
// Might not be thread safe.
event *events.State
startTime time.Time
running bool
ticks uint64
width int32
height int32
width int
height int
// Command line shell options.
shell Shell
@ -47,8 +52,8 @@ func New(debug bool, engine render.Engine) *Doodle {
Engine: engine,
startTime: time.Now(),
running: true,
width: int32(balance.Width),
height: int32(balance.Height),
width: balance.Width,
height: balance.Height,
}
d.shell = NewShell(d)
@ -97,6 +102,7 @@ func (d *Doodle) Run() error {
d.running = false
break
}
d.event = ev
// Command line shell.
if d.shell.Open {

View File

@ -11,6 +11,7 @@ import (
"git.kirsle.net/apps/doodle/enum"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
)
@ -103,6 +104,18 @@ func (s *EditorScene) Setup(d *Doodle) error {
// Loop the editor scene.
func (s *EditorScene) Loop(d *Doodle, ev *events.State) error {
// Has the window been resized?
if resized := ev.Resized.Read(); resized {
w, h := d.Engine.WindowSize()
if w != d.width || h != d.height {
// Not a false alarm.
d.width = w
d.height = h
s.UI.Resized(d)
return nil
}
}
s.UI.Loop(ev)
// Switching to Play Mode?
@ -133,6 +146,7 @@ func (s *EditorScene) LoadLevel(filename string) error {
s.filename = filename
level, err := level.LoadJSON(filename)
fmt.Printf("%+v\n", level)
if err != nil {
return fmt.Errorf("EditorScene.LoadLevel(%s): %s", filename, err)
}
@ -140,6 +154,20 @@ func (s *EditorScene) LoadLevel(filename string) error {
s.DrawingType = enum.LevelDrawing
s.Level = level
s.UI.Canvas.LoadLevel(s.Level)
// TODO: debug
for i, actor := range level.Actors {
log.Info("Actor %s is a %s", i, actor.ID())
}
for name, file := range level.Files {
log.Info("File %s has: %s", name, file.Data)
}
log.Info("Installing %d actors into the drawing", len(level.Actors))
if err := s.UI.Canvas.InstallActors(level.Actors); err != nil {
return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err)
}
return nil
}
@ -150,8 +178,8 @@ func (s *EditorScene) SaveLevel(filename string) error {
return errors.New("SaveLevel: current drawing is not a Level type")
}
if !strings.HasSuffix(filename, extLevel) {
filename += extLevel
if !strings.HasSuffix(filename, enum.LevelExt) {
filename += enum.LevelExt
}
s.filename = filename
@ -173,7 +201,7 @@ func (s *EditorScene) SaveLevel(filename string) error {
}
// Save it to their profile directory.
filename = LevelPath(filename)
filename = userdir.LevelPath(filename)
log.Info("Write Level: %s", filename)
err = ioutil.WriteFile(filename, json, 0644)
if err != nil {
@ -205,8 +233,8 @@ func (s *EditorScene) SaveDoodad(filename string) error {
return errors.New("SaveDoodad: current drawing is not a Doodad type")
}
if !strings.HasSuffix(filename, extDoodad) {
filename += extDoodad
if !strings.HasSuffix(filename, enum.DoodadExt) {
filename += enum.DoodadExt
}
s.filename = filename
@ -223,7 +251,7 @@ func (s *EditorScene) SaveDoodad(filename string) error {
d.Layers[0].Chunker = s.UI.Canvas.Chunker()
// Save it to their profile directory.
filename = DoodadPath(filename)
filename = userdir.DoodadPath(filename)
log.Info("Write Doodad: %s", filename)
err := d.WriteJSON(filename)
return err
@ -231,5 +259,6 @@ func (s *EditorScene) SaveDoodad(filename string) error {
// Destroy the scene.
func (s *EditorScene) Destroy() error {
debugWorldIndex = render.Origin
return nil
}

View File

@ -9,11 +9,15 @@ import (
"git.kirsle.net/apps/doodle/enum"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level"
"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"
)
// Width of the panel frame.
var paletteWidth int32 = 150
// EditorUI manages the user interface for the Editor Scene.
type EditorUI struct {
d *Doodle
@ -70,6 +74,8 @@ func NewEditorUI(d *Doodle, s *EditorScene) *EditorUI {
u.Palette = u.SetupPalette(d)
u.Workspace = u.SetupWorkspace(d) // important that this is last!
u.Resized(d)
// Position the Canvas inside the frame.
u.Workspace.Pack(u.Canvas, ui.Pack{
Anchor: ui.N,
@ -84,14 +90,75 @@ func NewEditorUI(d *Doodle, s *EditorScene) *EditorUI {
return u
}
// Resized handles the window being resized so we can recompute the widgets.
func (u *EditorUI) Resized(d *Doodle) {
// Menu Bar frame.
{
u.MenuBar.Configure(ui.Config{
Width: int32(d.width),
Background: render.Black,
})
u.MenuBar.Compute(d.Engine)
}
// Status Bar.
{
u.StatusBar.Configure(ui.Config{
Width: int32(d.width),
})
u.StatusBar.MoveTo(render.Point{
X: 0,
Y: int32(d.height) - u.StatusBar.Size().H,
})
u.StatusBar.Compute(d.Engine)
}
// Palette panel.
{
u.Palette.Configure(ui.Config{
Width: paletteWidth,
Height: int32(u.d.height) - u.StatusBar.Size().H,
})
u.Palette.MoveTo(render.NewPoint(
int32(u.d.width)-u.Palette.BoxSize().W,
u.MenuBar.BoxSize().H,
))
u.Palette.Compute(d.Engine)
}
// Position the workspace around with the other widgets.
{
frame := u.Workspace
frame.MoveTo(render.NewPoint(
0,
u.MenuBar.Size().H,
))
frame.Resize(render.NewRect(
int32(d.width)-u.Palette.Size().W,
int32(d.height)-u.MenuBar.Size().H-u.StatusBar.Size().H,
))
frame.Compute(d.Engine)
u.ExpandCanvas(d.Engine)
}
}
// Loop to process events and update the UI.
func (u *EditorUI) Loop(ev *events.State) {
u.Supervisor.Loop(ev)
u.StatusMouseText = fmt.Sprintf("Mouse: (%d,%d)",
{
var P = u.Workspace.Point()
debugWorldIndex = render.NewPoint(
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.CursorY.Now,
debugWorldIndex,
)
}
u.StatusPaletteText = fmt.Sprintf("Swatch: %s",
u.Canvas.Palette.ActiveSwatch,
)
@ -139,18 +206,6 @@ func (u *EditorUI) Present(e render.Engine) {
// Canvas, so it can easily full-screen it or center it for Doodad editing.
func (u *EditorUI) SetupWorkspace(d *Doodle) *ui.Frame {
frame := ui.NewFrame("Workspace")
// Position and size the frame around the other main widgets.
frame.MoveTo(render.NewPoint(
0,
u.MenuBar.Size().H,
))
frame.Resize(render.NewRect(
d.width-u.Palette.Size().W,
d.height-u.MenuBar.Size().H-u.StatusBar.Size().H,
))
frame.Compute(d.Engine)
return frame
}
@ -170,17 +225,17 @@ func (u *EditorUI) SetupCanvas(d *Doodle) *uix.Canvas {
// in its frame, but that would artificially expand the Canvas also when it
// _wanted_ to be smaller, as in Doodad Editing Mode.
func (u *EditorUI) ExpandCanvas(e render.Engine) {
if u.Scene.DrawingType == enum.LevelDrawing {
u.Canvas.Resize(u.Workspace.Size())
} else {
// Size is managed externally.
}
u.Workspace.Compute(e)
}
// SetupMenuBar sets up the menu bar.
func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.Frame {
frame := ui.NewFrame("MenuBar")
frame.Configure(ui.Config{
Width: d.width,
Background: render.Black,
})
type menuButton struct {
Text string
@ -293,21 +348,13 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.Frame {
// SetupPalette sets up the palette panel.
func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
var paletteWidth int32 = 150
window := ui.NewWindow("Palette")
window.ConfigureTitle(balance.TitleConfig)
window.TitleBar().Font = balance.TitleFont
window.Configure(ui.Config{
Width: paletteWidth,
Height: u.d.height - u.StatusBar.Size().H,
Background: balance.WindowBackground,
BorderColor: balance.WindowBorder,
})
window.MoveTo(render.NewPoint(
u.d.width-window.BoxSize().W,
u.MenuBar.BoxSize().H,
))
// Frame that holds the tab buttons in Level Edit mode.
tabFrame := ui.NewFrame("Palette Tabs")
@ -355,7 +402,7 @@ func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
Fill: true,
})
doodadsAvailable, err := ListDoodads()
doodadsAvailable, err := userdir.ListDoodads()
if err != nil {
d.Flash("ListDoodads: %s", err)
}
@ -376,7 +423,7 @@ func (u *EditorUI) SetupPalette(d *Doodle) *ui.Window {
})
}
doodad, err := doodads.LoadJSON(DoodadPath(filename))
doodad, err := doodads.LoadJSON(userdir.DoodadPath(filename))
if err != nil {
log.Error(err.Error())
doodad = doodads.New(balance.DoodadSize)
@ -459,7 +506,6 @@ func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame {
BorderStyle: ui.BorderRaised,
Background: render.Grey,
BorderSize: 2,
Width: d.width,
})
style := ui.Config{
@ -503,15 +549,13 @@ func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame {
Anchor: ui.E,
})
// Set the initial good frame size to have the height secured,
// so when resizing the application window we can just adjust for width.
frame.Resize(render.Rect{
W: d.width,
W: int32(d.width),
H: labelHeight + frame.BoxThickness(1),
})
frame.Compute(d.Engine)
frame.MoveTo(render.Point{
X: 0,
Y: d.height - frame.Size().H,
})
return frame
}

View File

@ -10,3 +10,9 @@ const (
LevelDrawing DrawingType = iota
DoodadDrawing
)
// File extensions
const (
LevelExt = ".level"
DoodadExt = ".doodad"
)

View File

@ -25,6 +25,9 @@ type State struct {
// Cursor positions.
CursorX *Int32Tick
CursorY *Int32Tick
// Window events: window has changed size.
Resized *BoolTick
}
// New creates a new event state manager.
@ -43,6 +46,7 @@ func New() *State {
Down: &BoolTick{},
CursorX: &Int32Tick{},
CursorY: &Int32Tick{},
Resized: &BoolTick{},
}
}

74
fps.go
View File

@ -2,9 +2,12 @@ package doodle
import (
"fmt"
"strings"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
// Frames to cache for FPS calculation.
@ -15,6 +18,12 @@ const maxSamples = 100
var (
DebugOverlay = true
DebugCollision = true
DebugTextPadding int32 = 8
DebugTextSize = 24
DebugTextColor = render.SkyBlue
DebugTextStroke = render.Grey
DebugTextShadow = render.Black
)
var (
@ -24,6 +33,11 @@ var (
fpsFrames int
fpsSkipped uint32
fpsInterval uint32 = 1000
// XXX: some opt-in WorldIndex variables for the debug overlay.
// This is the world pixel that the mouse cursor is over,
// the Cursor + Scroll position of the canvas.
debugWorldIndex render.Point
)
// DrawDebugOverlay draws the debug FPS text on the SDL canvas.
@ -32,29 +46,53 @@ func (d *Doodle) DrawDebugOverlay() {
return
}
label := fmt.Sprintf(
"FPS: %d (%dms) S:%s F12=screenshot",
fpsCurrent,
fpsSkipped,
var (
darken = balance.DebugStrokeDarken
Yoffset int32 = 20 // leave room for the menu bar
Xoffset int32 = 5
keys = []string{
" FPS:",
"Scene:",
"Pixel:",
"Mouse:",
}
values = []string{
fmt.Sprintf("%d (skip: %dms)", fpsCurrent, fpsSkipped),
d.Scene.Name(),
debugWorldIndex.String(),
fmt.Sprintf("%d,%d", d.event.CursorX.Now, d.event.CursorY.Now),
}
)
err := d.Engine.DrawText(
render.Text{
Text: label,
Size: 24,
Color: DebugTextColor,
Stroke: DebugTextStroke,
Shadow: DebugTextShadow,
key := ui.NewLabel(ui.Label{
Text: strings.Join(keys, "\n"),
Font: render.Text{
Size: balance.DebugFontSize,
FontFilename: balance.ShellFontFilename,
Color: balance.DebugLabelColor,
Stroke: balance.DebugLabelColor.Darken(darken),
},
render.Point{
X: DebugTextPadding,
Y: DebugTextPadding + 32, // extra padding to not overlay menu bars
})
key.Compute(d.Engine)
key.Present(d.Engine, render.NewPoint(
DebugTextPadding+Xoffset,
DebugTextPadding+Yoffset,
))
value := ui.NewLabel(ui.Label{
Text: strings.Join(values, "\n"),
Font: render.Text{
Size: balance.DebugFontSize,
FontFilename: balance.DebugFontFilename,
Color: balance.DebugValueColor,
Stroke: balance.DebugValueColor.Darken(darken),
},
)
if err != nil {
log.Error("DrawDebugOverlay: text error: %s", err.Error())
}
})
value.Compute(d.Engine)
value.Present(d.Engine, render.NewPoint(
DebugTextPadding+Xoffset+key.Size().W+DebugTextPadding,
DebugTextPadding+Yoffset, // padding to not overlay menu bar
))
}
// DrawCollisionBox draws the collision box around a Doodad.

View File

@ -275,14 +275,14 @@ func (s *GUITestScene) Draw(d *Doodle) error {
})
label.Compute(d.Engine)
label.MoveTo(render.Point{
X: (d.width / 2) - (label.Size().W / 2),
X: (int32(d.width) / 2) - (label.Size().W / 2),
Y: 40,
})
label.Present(d.Engine, label.Point())
s.Window.Compute(d.Engine)
s.Window.MoveTo(render.Point{
X: (d.width / 2) - (s.Window.Size().W / 2),
X: (int32(d.width) / 2) - (s.Window.Size().W / 2),
Y: 100,
})
s.Window.Present(d.Engine, s.Window.Point())

5
kirsle.env Normal file
View File

@ -0,0 +1,5 @@
#!/bin/bash
export D_SHELL_BG="#001133DD"
export D_SHELL_FG="#FF99FF"
export D_SHELL_PC="#FF9900"

25
level/actors.go Normal file
View File

@ -0,0 +1,25 @@
package level
import "git.kirsle.net/apps/doodle/render"
// ActorMap holds the doodad information by their ID in the level data.
type ActorMap map[string]*Actor
// Inflate assigns each actor its ID from the hash map for their self reference.
func (m ActorMap) Inflate() {
for id, actor := range m {
actor.id = id
}
}
// Actor is an instance of a Doodad in the level.
type Actor struct {
id string // NOTE: read only, use ID() to access.
Filename string `json:"filename"` // like "exit.doodad"
Point render.Point `json:"point"`
}
// ID returns the actor's ID.
func (a *Actor) ID() string {
return a.id
}

9
level/filesystem.go Normal file
View File

@ -0,0 +1,9 @@
package level
// FileSystem embeds a map of files inside a parent drawing.
type FileSystem map[string]File
// File holds details about a file in the FileSystem.
type File struct {
Data []byte `json:"data"`
}

View File

@ -50,6 +50,7 @@ func LoadJSON(filename string) (*Level, error) {
// Inflate the chunk metadata to map the pixels to their palette indexes.
m.Chunker.Inflate(m.Palette)
m.Actors.Inflate()
// Inflate the private instance values.
m.Palette.Inflate()

View File

@ -15,6 +15,9 @@ type Base struct {
GameVersion string `json:"gameVersion"` // Game version that created the level.
Title string `json:"title"`
Author string `json:"author"`
// Every drawing type is able to embed other files inside of itself.
Files FileSystem `json:"files"`
}
// Level is the container format for Doodle map drawings.
@ -29,6 +32,9 @@ type Level struct {
// The Palette holds the unique "colors" used in this map file, and their
// properties (solid, fire, slippery, etc.)
Palette *Palette `json:"palette"`
// Actors keep a list of the doodad instances in this map.
Actors ActorMap `json:"actors"`
}
// New creates a blank level object with all its members initialized.

View File

@ -77,14 +77,14 @@ func (s *MainScene) Draw(d *Doodle) error {
})
label.Compute(d.Engine)
label.MoveTo(render.Point{
X: (d.width / 2) - (label.Size().W / 2),
X: (int32(d.width) / 2) - (label.Size().W / 2),
Y: 120,
})
label.Present(d.Engine, label.Point())
s.frame.Compute(d.Engine)
s.frame.MoveTo(render.Point{
X: (d.width / 2) - (s.frame.Size().W / 2),
X: (int32(d.width) / 2) - (s.frame.Size().W / 2),
Y: 200,
})
s.frame.Present(d.Engine, s.frame.Point())

124
pkg/userdir/userdir.go Normal file
View File

@ -0,0 +1,124 @@
package userdir
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/kirsle/configdir"
)
// Profile Directory settings.
var (
ConfigDirectoryName = "doodle"
ProfileDirectory string
LevelDirectory string
DoodadDirectory string
CacheDirectory string
FontDirectory string
)
// File extensions
const (
extLevel = ".level"
extDoodad = ".doodad"
)
func init() {
// Profile directory contains the user's levels and doodads.
ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName)
LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels")
DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads")
// Cache directory to extract font files to.
CacheDirectory = configdir.LocalCache(ConfigDirectoryName)
FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts")
// Ensure all the directories exist.
configdir.MakePath(LevelDirectory)
configdir.MakePath(DoodadDirectory)
configdir.MakePath(FontDirectory)
}
// LevelPath will turn a "simple" filename into an absolute path in the user's
// local levels folder. If the filename already contains slashes, it is returned
// as-is as an absolute or relative path.
func LevelPath(filename string) string {
return resolvePath(LevelDirectory, filename, extLevel)
}
// DoodadPath is like LevelPath but for Doodad files.
func DoodadPath(filename string) string {
return resolvePath(DoodadDirectory, filename, extDoodad)
}
// ListDoodads returns a listing of all available doodads.
func ListDoodads() ([]string, error) {
var names []string
files, err := ioutil.ReadDir(DoodadDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if strings.HasSuffix(strings.ToLower(name), extDoodad) {
names = append(names, name)
}
}
return names, nil
}
// resolvePath is the inner logic for LevelPath and DoodadPath.
func resolvePath(directory, filename, extension string) string {
if strings.Contains(filename, "/") {
return filename
}
// Attach the file extension?
if strings.ToLower(filepath.Ext(filename)) != extension {
filename += extension
}
return filepath.Join(directory, filename)
}
// ResolvePath takes an ambiguous simple filename and searches for a Level or
// Doodad that matches. Returns a blank string if no files found.
//
// Pass a true value for `one` if you are intending to create the file. It will
// only test one filepath and return the first one, regardless if the file
// existed. So the filename should have a ".level" or ".doodad" extension and
// then this path will resolve the ProfileDirectory of the file.
func ResolvePath(filename, extension string, one bool) string {
// If the filename exists outright, return it.
if _, err := os.Stat(filename); !os.IsNotExist(err) {
return filename
}
var paths []string
if extension == extLevel {
paths = append(paths, filepath.Join(LevelDirectory, filename))
} else if extension == extDoodad {
paths = append(paths, filepath.Join(DoodadDirectory, filename))
} else {
paths = append(paths,
filepath.Join(LevelDirectory, filename+".level"),
filepath.Join(DoodadDirectory, filename+".doodad"),
)
}
for _, test := range paths {
if _, err := os.Stat(test); os.IsNotExist(err) {
continue
}
return test
}
return ""
}

View File

@ -33,7 +33,7 @@ func (s *PlayScene) Name() string {
func (s *PlayScene) Setup(d *Doodle) error {
s.drawing = uix.NewCanvas(balance.ChunkSize, false)
s.drawing.MoveTo(render.Origin)
s.drawing.Resize(render.NewRect(d.width, d.height))
s.drawing.Resize(render.NewRect(int32(d.width), int32(d.height)))
s.drawing.Compute(d.Engine)
// Given a filename or map data to play?

View File

@ -15,6 +15,7 @@ type Engine interface {
// Poll for events like keypresses and mouse clicks.
Poll() (*events.State, error)
GetTicks() uint32
WindowSize() (w, h int)
// Present presents the current state to the screen.
Present() error
@ -88,6 +89,27 @@ func (r Rect) Bigger(other Rect) bool {
other.H > r.H) // Taller
}
// Intersects with the other rectangle in any way.
func (r Rect) Intersects(other Rect) bool {
// Do a bidirectional compare.
compare := func(a, b Rect) bool {
var corners = []Point{
NewPoint(b.X, b.Y),
NewPoint(b.X, b.Y+b.H),
NewPoint(b.X+b.W, b.Y),
NewPoint(b.X+b.W, b.Y+b.H),
}
for _, pt := range corners {
if pt.Inside(a) {
return true
}
}
return false
}
return compare(r, other) || compare(other, r) || false
}
// IsZero returns if the Rect is uninitialized.
func (r Rect) IsZero() bool {
return r.X == 0 && r.Y == 0 && r.W == 0 && r.H == 0

View File

@ -55,6 +55,9 @@ func (p Point) IsZero() bool {
}
// Inside returns whether the Point falls inside the rect.
//
// NOTICE: the W and H are zero-relative, so a 100x100 box at coordinate
// X,Y would still have W,H of 100.
func (p Point) Inside(r Rect) bool {
var (
x1 = r.X
@ -62,7 +65,8 @@ func (p Point) Inside(r Rect) bool {
x2 = r.X + r.W
y2 = r.Y + r.H
)
return p.X >= x1 && p.X <= x2 && p.Y >= y1 && p.Y <= y2
return ((p.X >= x1 && p.X <= x2) &&
(p.Y >= y1 && p.Y <= y2))
}
// Add (or subtract) the other point to your current point.

View File

@ -8,13 +8,9 @@ import (
)
func TestPointInside(t *testing.T) {
var p = render.Point{
X: 128,
Y: 256,
}
type testCase struct {
rect render.Rect
p render.Point
shouldPass bool
}
tests := []testCase{
@ -25,6 +21,7 @@ func TestPointInside(t *testing.T) {
W: 500,
H: 500,
},
p: render.NewPoint(128, 256),
shouldPass: true,
},
testCase{
@ -34,14 +31,27 @@ func TestPointInside(t *testing.T) {
W: 40,
H: 60,
},
p: render.NewPoint(128, 256),
shouldPass: false,
},
testCase{
// true values when debugging why Doodads weren't
// considered inside the viewport.
rect: render.Rect{
X: 0,
Y: -232,
H: 874,
W: 490,
},
p: render.NewPoint(509, 260),
shouldPass: true,
},
}
for _, test := range tests {
if p.Inside(test.rect) != test.shouldPass {
t.Errorf("Failed: %s inside %s should %s",
p,
if test.p.Inside(test.rect) != test.shouldPass {
t.Errorf("Failed: %s inside %s should be %s",
test.p,
test.rect,
strconv.FormatBool(test.shouldPass),
)

71
render/rect_test.go Normal file
View File

@ -0,0 +1,71 @@
package render_test
import (
"strconv"
"testing"
"git.kirsle.net/apps/doodle/render"
)
func TestIntersection(t *testing.T) {
newRect := func(x, y, w, h int) render.Rect {
return render.Rect{
X: int32(x),
Y: int32(y),
W: int32(w),
H: int32(h),
}
}
type TestCase struct {
A render.Rect
B render.Rect
Expect bool
}
var tests = []TestCase{
{
A: newRect(0, 0, 1000, 1000),
B: newRect(200, 200, 100, 100),
Expect: true,
},
{
A: newRect(200, 200, 100, 100),
B: newRect(0, 0, 1000, 1000),
Expect: true,
},
{
A: newRect(0, 0, 100, 100),
B: newRect(100, 0, 100, 100),
Expect: true,
},
{
A: newRect(0, 0, 99, 99),
B: newRect(100, 0, 99, 99),
Expect: false,
},
{
// Real coords of a test doodad!
A: newRect(183, 256, 283, 356),
B: newRect(0, -232, 874, 490),
Expect: true,
},
{
A: newRect(183, 256, 283, 356),
B: newRect(0, -240, 874, 490),
Expect: false, // XXX: must be true
},
}
for _, test := range tests {
actual := test.A.Intersects(test.B)
if actual != test.Expect {
t.Errorf(
"%s collision with %s: expected %s, got %s",
test.A,
test.B,
strconv.FormatBool(test.Expect),
strconv.FormatBool(actual),
)
}
}
}

127
render/sdl/events.go Normal file
View File

@ -0,0 +1,127 @@
package sdl
import (
"errors"
"git.kirsle.net/apps/doodle/events"
"github.com/veandco/go-sdl2/sdl"
)
// Poll for events.
func (r *Renderer) Poll() (*events.State, error) {
s := r.events
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
switch t := event.(type) {
case *sdl.QuitEvent:
return s, errors.New("quit")
case *sdl.WindowEvent:
if DebugWindowEvents {
if t.Event == sdl.WINDOWEVENT_RESIZED {
log.Debug("[%d ms] tick:%d Window Resized to %dx%d",
t.Timestamp,
r.ticks,
t.Data1,
t.Data2,
)
}
}
s.Resized.Push(true)
case *sdl.MouseMotionEvent:
if DebugMouseEvents {
log.Debug("[%d ms] tick:%d MouseMotion type:%d id:%d x:%d y:%d xrel:%d yrel:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.XRel, t.YRel,
)
}
// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)
s.Button1.Push(t.State == 1)
case *sdl.MouseButtonEvent:
if DebugClickEvents {
log.Debug("[%d ms] tick:%d MouseButton type:%d id:%d x:%d y:%d button:%d state:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.Button, t.State,
)
}
// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)
// Is a mouse button pressed down?
if t.Button == 1 {
var eventName string
if t.State == 1 && s.Button1.Now == false {
eventName = "DOWN"
} else if t.State == 0 && s.Button1.Now == true {
eventName = "UP"
}
if eventName != "" {
s.Button1.Push(eventName == "DOWN")
// Return the event immediately.
return s, nil
}
}
// s.Button2.Push(t.Button == 3 && t.State == 1)
case *sdl.MouseWheelEvent:
if DebugMouseEvents {
log.Debug("[%d ms] tick:%d MouseWheel type:%d id:%d x:%d y:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y,
)
}
case *sdl.KeyboardEvent:
if DebugKeyEvents {
log.Debug("[%d ms] tick:%d Keyboard type:%d sym:%c modifiers:%d state:%d repeat:%d\n",
t.Timestamp, r.ticks, t.Type, t.Keysym.Sym, t.Keysym.Mod, t.State, t.Repeat,
)
}
switch t.Keysym.Scancode {
case sdl.SCANCODE_ESCAPE:
if t.Repeat == 1 {
continue
}
s.EscapeKey.Push(t.State == 1)
case sdl.SCANCODE_RETURN:
if t.Repeat == 1 {
continue
}
s.EnterKey.Push(t.State == 1)
case sdl.SCANCODE_F12:
s.ScreenshotKey.Push(t.State == 1)
case sdl.SCANCODE_UP:
s.Up.Push(t.State == 1)
case sdl.SCANCODE_LEFT:
s.Left.Push(t.State == 1)
case sdl.SCANCODE_RIGHT:
s.Right.Push(t.State == 1)
case sdl.SCANCODE_DOWN:
s.Down.Push(t.State == 1)
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_BACKSPACE:
// Make it a key event with "\b" as the sequence.
if t.State == 1 || t.Repeat == 1 {
s.KeyName.Push(`\b`)
}
default:
// Push the string value of the key.
if t.State == 1 {
s.KeyName.Push(string(t.Keysym.Sym))
}
}
}
}
return s, nil
}

View File

@ -9,6 +9,7 @@ var (
DebugMouseEvents = false
DebugClickEvents = false
DebugKeyEvents = false
DebugWindowEvents = false
)
func init() {

View File

@ -2,7 +2,6 @@
package sdl
import (
"errors"
"time"
"git.kirsle.net/apps/doodle/events"
@ -69,7 +68,7 @@ func (r *Renderer) Setup() error {
sdl.WINDOWPOS_CENTERED,
r.width,
r.height,
sdl.WINDOW_SHOWN,
sdl.WINDOW_SHOWN|sdl.WINDOW_RESIZABLE,
)
if err != nil {
return err
@ -93,111 +92,10 @@ func (r *Renderer) GetTicks() uint32 {
return sdl.GetTicks()
}
// Poll for events.
func (r *Renderer) Poll() (*events.State, error) {
s := r.events
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
switch t := event.(type) {
case *sdl.QuitEvent:
return s, errors.New("quit")
case *sdl.MouseMotionEvent:
if DebugMouseEvents {
log.Debug("[%d ms] tick:%d MouseMotion type:%d id:%d x:%d y:%d xrel:%d yrel:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.XRel, t.YRel,
)
}
// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)
s.Button1.Push(t.State == 1)
case *sdl.MouseButtonEvent:
if DebugClickEvents {
log.Debug("[%d ms] tick:%d MouseButton type:%d id:%d x:%d y:%d button:%d state:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y, t.Button, t.State,
)
}
// Push the cursor position.
s.CursorX.Push(t.X)
s.CursorY.Push(t.Y)
// Is a mouse button pressed down?
if t.Button == 1 {
var eventName string
if t.State == 1 && s.Button1.Now == false {
eventName = "DOWN"
} else if t.State == 0 && s.Button1.Now == true {
eventName = "UP"
}
if eventName != "" {
s.Button1.Push(eventName == "DOWN")
// Return the event immediately.
return s, nil
}
}
// s.Button2.Push(t.Button == 3 && t.State == 1)
case *sdl.MouseWheelEvent:
if DebugMouseEvents {
log.Debug("[%d ms] tick:%d MouseWheel type:%d id:%d x:%d y:%d",
t.Timestamp, r.ticks, t.Type, t.Which, t.X, t.Y,
)
}
case *sdl.KeyboardEvent:
if DebugKeyEvents {
log.Debug("[%d ms] tick:%d Keyboard type:%d sym:%c modifiers:%d state:%d repeat:%d\n",
t.Timestamp, r.ticks, t.Type, t.Keysym.Sym, t.Keysym.Mod, t.State, t.Repeat,
)
}
switch t.Keysym.Scancode {
case sdl.SCANCODE_ESCAPE:
if t.Repeat == 1 {
continue
}
s.EscapeKey.Push(t.State == 1)
case sdl.SCANCODE_RETURN:
if t.Repeat == 1 {
continue
}
s.EnterKey.Push(t.State == 1)
case sdl.SCANCODE_F12:
s.ScreenshotKey.Push(t.State == 1)
case sdl.SCANCODE_UP:
s.Up.Push(t.State == 1)
case sdl.SCANCODE_LEFT:
s.Left.Push(t.State == 1)
case sdl.SCANCODE_RIGHT:
s.Right.Push(t.State == 1)
case sdl.SCANCODE_DOWN:
s.Down.Push(t.State == 1)
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_BACKSPACE:
// Make it a key event with "\b" as the sequence.
if t.State == 1 || t.Repeat == 1 {
s.KeyName.Push(`\b`)
}
default:
// Push the string value of the key.
if t.State == 1 {
s.KeyName.Push(string(t.Keysym.Sym))
}
}
}
}
return s, nil
// WindowSize returns the SDL window size.
func (r *Renderer) WindowSize() (int, int) {
w, h := r.window.GetSize()
return int(w), int(h)
}
// Present the current frame.

View File

@ -281,14 +281,14 @@ func (s *Shell) Draw(d *Doodle, ev *events.State) error {
balance.ShellBackgroundColor,
render.Rect{
X: 0,
Y: d.height - boxHeight,
W: d.width,
Y: int32(d.height) - boxHeight,
W: int32(d.width),
H: boxHeight,
},
)
// Draw the recent commands.
outputY := d.height - int32(lineHeight*2)
outputY := int32(d.height - (lineHeight * 2))
for i := 0; i < balance.ShellHistoryLineCount; i++ {
if len(s.Output) > i {
line := s.Output[len(s.Output)-1-i]
@ -318,14 +318,14 @@ func (s *Shell) Draw(d *Doodle, ev *events.State) error {
},
render.Point{
X: balance.ShellPadding,
Y: d.height - int32(balance.ShellFontSize) - balance.ShellPadding,
Y: int32(d.height-balance.ShellFontSize) - balance.ShellPadding,
},
)
} else if len(s.Flashes) > 0 {
// Otherwise, just draw flashed messages.
valid := false // Did we actually draw any?
outputY := d.height - int32(lineHeight*2)
outputY := int32(d.height - (lineHeight * 2))
for i := len(s.Flashes); i > 0; i-- {
flash := s.Flashes[i-1]
if d.ticks >= flash.Expires {

View File

@ -2,6 +2,7 @@ package ui
import (
"fmt"
"strings"
"git.kirsle.net/apps/doodle/render"
)
@ -23,6 +24,7 @@ type Label struct {
width int32
height int32
lineHeight int
}
// NewLabel creates a new label.
@ -60,12 +62,26 @@ func (w *Label) Value() string {
// Compute the size of the label widget.
func (w *Label) Compute(e render.Engine) {
rect, err := e.ComputeTextRect(w.text())
text := w.text()
lines := strings.Split(text.Text, "\n")
// Max rect to encompass all lines of text.
var maxRect = render.Rect{}
for _, line := range lines {
text.Text = line // only this line at this time.
rect, err := e.ComputeTextRect(text)
if err != nil {
log.Error("%s: failed to compute text rect: %s", w, err)
return
}
if rect.W > maxRect.W {
maxRect.W = rect.W
}
maxRect.H += rect.H
w.lineHeight = int(rect.H)
}
var (
padX = w.Font.Padding + w.Font.PadX
padY = w.Font.Padding + w.Font.PadY
@ -73,14 +89,14 @@ func (w *Label) Compute(e render.Engine) {
if !w.FixedSize() {
w.resizeAuto(render.Rect{
W: rect.W + (padX * 2),
H: rect.H + (padY * 2),
W: maxRect.W + (padX * 2),
H: maxRect.H + (padY * 2),
})
}
w.MoveTo(render.Point{
X: rect.X + w.BoxThickness(1),
Y: rect.Y + w.BoxThickness(1),
X: maxRect.X + w.BoxThickness(1),
Y: maxRect.Y + w.BoxThickness(1),
})
}
@ -93,13 +109,17 @@ func (w *Label) Present(e render.Engine, P render.Point) {
border := w.BoxThickness(1)
var (
text = w.text()
padX = w.Font.Padding + w.Font.PadX
padY = w.Font.Padding + w.Font.PadY
)
w.DrawBox(e, P)
e.DrawText(w.text(), render.Point{
for i, line := range strings.Split(text.Text, "\n") {
text.Text = line
e.DrawText(text, render.Point{
X: P.X + border + padX,
Y: P.Y + border + padY,
Y: P.Y + border + padY + int32(i*w.lineHeight),
})
}
}

View File

@ -8,6 +8,7 @@ import (
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
@ -21,7 +22,14 @@ type Canvas struct {
Editable bool
Scrollable bool
// Underlying chunk data for the drawing.
chunks *level.Chunker
// Actors to superimpose on top of the drawing.
actor *level.Actor // if this canvas IS an actor
actors []*Actor
// Tracking pixels while editing. TODO: get rid of pixelHistory?
pixelHistory []*level.Pixel
lastPixel *level.Pixel
@ -29,6 +37,12 @@ type Canvas struct {
Scroll render.Point // Scroll offset for which parts of canvas are visible.
}
// Actor is an instance of an actor with a Canvas attached.
type Actor struct {
Actor *level.Actor
Canvas *Canvas
}
// NewCanvas initializes a Canvas widget.
//
// If editable is true, Scrollable is also set to true, which means the arrow
@ -39,6 +53,7 @@ func NewCanvas(size int, editable bool) *Canvas {
Scrollable: editable,
Palette: level.NewPalette(),
chunks: level.NewChunker(size),
actors: make([]*Actor, 0),
}
w.setup()
w.IDFunc(func() string {
@ -80,6 +95,32 @@ func (w *Canvas) LoadDoodad(d *doodads.Doodad) {
w.Load(d.Palette, d.Layers[0].Chunker)
}
// InstallActors adds external Actors to the canvas to be superimposed on top
// of the drawing.
func (w *Canvas) InstallActors(actors level.ActorMap) error {
w.actors = make([]*Actor, 0)
for id, actor := range actors {
log.Info("InstallActors: %s", id)
doodad, err := doodads.LoadJSON(userdir.DoodadPath(actor.Filename))
if err != nil {
return fmt.Errorf("InstallActors: %s", err)
}
size := int32(doodad.Layers[0].Chunker.Size)
can := NewCanvas(int(size), false)
can.Name = id
can.actor = actor
can.LoadDoodad(doodad)
can.Resize(render.NewRect(size, size))
w.actors = append(w.actors, &Actor{
Actor: actor,
Canvas: can,
})
}
return nil
}
// SetSwatch changes the currently selected swatch for editing.
func (w *Canvas) SetSwatch(s *level.Swatch) {
w.Palette.ActiveSwatch = s
@ -88,6 +129,16 @@ func (w *Canvas) SetSwatch(s *level.Swatch) {
// setup common configs between both initializers of the canvas.
func (w *Canvas) setup() {
w.SetBackground(render.White)
// XXX: Debug code.
if balance.DebugCanvasBorder != render.Invisible {
w.Configure(ui.Config{
BorderColor: balance.DebugCanvasBorder,
BorderSize: 2,
BorderStyle: ui.BorderSolid,
})
}
w.Handle(ui.MouseOver, func(p render.Point) {
w.SetBackground(render.Yellow)
})
@ -145,14 +196,6 @@ func (w *Canvas) Loop(ev *events.State) error {
Swatch: w.Palette.ActiveSwatch,
}
log.Warn(
"real cursor: %d,%d translated: %s widget pos: %s scroll: %s",
ev.CursorX.Now, ev.CursorY.Now,
cursor,
P,
w.Scroll,
)
// Append unique new pixels.
if len(w.pixelHistory) == 0 || w.pixelHistory[len(w.pixelHistory)-1] != pixel {
if lastPixel != nil {
@ -168,7 +211,6 @@ func (w *Canvas) Loop(ev *events.State) error {
w.pixelHistory = append(w.pixelHistory, pixel)
// Save in the pixel canvas map.
log.Info("Set: %s %s", cursor, pixel.Swatch.Color)
w.chunks.Set(cursor, pixel.Swatch)
}
} else {
@ -181,6 +223,15 @@ func (w *Canvas) Loop(ev *events.State) error {
// Viewport returns a rect containing the viewable drawing coordinates in this
// canvas. The X,Y values are the scroll offset (top left) and the W,H values
// are the scroll offset plus the width/height of the Canvas widget.
//
// The Viewport rect are the Absolute World Coordinates of the drawing that are
// visible inside the Canvas. The X,Y is the top left World Coordinate and the
// W,H are the bottom right World Coordinate, making this rect an absolute
// slice of the world. For a normal rect with a relative width and height,
// use ViewportRelative().
//
// The rect X,Y are the negative Scroll Value.
// The rect W,H are the Canvas widget size minus the Scroll Value.
func (w *Canvas) Viewport() render.Rect {
var S = w.Size()
return render.Rect{
@ -191,6 +242,22 @@ func (w *Canvas) Viewport() render.Rect {
}
}
// ViewportRelative returns a relative viewport where the Width and Height
// values are zero-relative: so you can use it with point.Inside(viewport)
// to see if a World Index point should be visible on screen.
//
// The rect X,Y are the negative Scroll Value
// The rect W,H are the Canvas widget size.
func (w *Canvas) ViewportRelative() render.Rect {
var S = w.Size()
return render.Rect{
X: -w.Scroll.X,
Y: -w.Scroll.Y,
W: S.W,
H: S.H,
}
}
// Chunker returns the underlying Chunker object.
func (w *Canvas) Chunker() *level.Chunker {
return w.chunks
@ -254,8 +321,8 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
// src.W and src.H will be AT MOST the full width and height of
// a Canvas widget. Subtract the scroll offset to keep it bounded
// visually on its right and bottom sides.
W: src.W, // - w.Scroll.X,
H: src.H, // - w.Scroll.Y,
W: src.W,
H: src.H,
}
// If the destination width will cause it to overflow the widget
@ -273,13 +340,13 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
if dst.X+src.W > p.X+S.W {
// NOTE: delta is a negative number,
// so it will subtract from the width.
delta := (S.W + p.X) - (dst.W + dst.X)
delta := (p.X + S.W - w.BoxThickness(1)) - (dst.W + dst.X)
src.W += delta
dst.W += delta
}
if dst.Y+src.H > p.Y+S.H {
// NOTE: delta is a negative number
delta := (S.H + p.Y) - (dst.H + dst.Y)
delta := (p.Y + S.H - w.BoxThickness(1)) - (dst.H + dst.Y)
src.H += delta
dst.H += delta
}
@ -298,30 +365,141 @@ func (w *Canvas) Present(e render.Engine, p render.Point) {
// NOTE: delta is a positive number,
// so it will add to the destination coordinates.
delta := p.X - dst.X
dst.X = p.X
dst.X = p.X + w.BoxThickness(1)
dst.W -= delta
src.X += delta
}
if dst.Y < p.Y {
delta := p.Y - dst.Y
dst.Y = p.Y
dst.Y = p.Y + w.BoxThickness(1)
dst.H -= delta
src.Y += delta
}
// Trim the destination width so it doesn't overlap the Canvas border.
if dst.W >= S.W-w.BoxThickness(1) {
dst.W = S.W - w.BoxThickness(1)
}
e.Copy(tex, src, dst)
}
}
// for px := range w.chunks.IterViewport(Viewport) {
// // This pixel is visible in the canvas, but offset it by the
// // scroll height.
// px.X -= Viewport.X
// px.Y -= Viewport.Y
// color := render.Cyan // px.Swatch.Color
// e.DrawPoint(color, render.Point{
// X: p.X + w.BoxThickness(1) + px.X,
// Y: p.Y + w.BoxThickness(1) + px.Y,
// })
// }
w.drawActors(e, p)
// XXX: Debug, show label in canvas corner.
if balance.DebugCanvasLabel {
rows := []string{
w.Name,
// XXX: debug options, uncomment for more details
// Size of the canvas
// fmt.Sprintf("S=%d,%d", S.W, S.H),
// Viewport of the canvas
// fmt.Sprintf("V=%d,%d:%d,%d",
// Viewport.X, Viewport.Y,
// Viewport.W, Viewport.H,
// ),
}
if w.actor != nil {
rows = append(rows,
fmt.Sprintf("WP=%s", w.actor.Point),
)
}
label := ui.NewLabel(ui.Label{
Text: strings.Join(rows, "\n"),
Font: render.Text{
FontFilename: balance.ShellFontFilename,
Size: balance.ShellFontSizeSmall,
Color: render.White,
},
})
label.SetBackground(render.RGBA(0, 0, 50, 150))
label.Compute(e)
label.Present(e, render.Point{
X: p.X + S.W - label.Size().W - w.BoxThickness(1),
Y: p.Y + w.BoxThickness(1),
})
}
}
// drawActors superimposes the actors on top of the drawing.
func (w *Canvas) drawActors(e render.Engine, p render.Point) {
var (
Viewport = w.ViewportRelative()
S = w.Size()
)
// See if each Actor is in range of the Viewport.
for _, a := range w.actors {
var (
actor = a.Actor // Static Actor instance from Level file, DO NOT CHANGE
can = a.Canvas // Canvas widget that draws the actor
actorPoint = actor.Point // XXX TODO: DO NOT CHANGE
actorSize = can.Size()
)
// Create a box of World Coordinates that this actor occupies. The
// Actor X,Y from level data is already a World Coordinate;
// accomodate for the size of the Actor.
actorBox := render.Rect{
X: actorPoint.X,
Y: actorPoint.Y,
W: actorSize.W,
H: actorSize.H,
}
// Is any part of the actor visible?
if !Viewport.Intersects(actorBox) {
continue // not visible on screen
}
drawAt := render.Point{
X: p.X + w.Scroll.X + actorPoint.X + w.BoxThickness(1),
Y: p.Y + w.Scroll.Y + actorPoint.Y + w.BoxThickness(1),
}
resizeTo := actorSize
// XXX TODO: when an Actor hits the left or top edge and shrinks,
// scrolling to offset that shrink is currently hard to solve.
scrollTo := render.Origin
// Handle cropping and scaling if this Actor's canvas can't be
// completely visible within the parent.
if drawAt.X+resizeTo.W > p.X+S.W {
// Hitting the right edge, shrunk the width now.
delta := (drawAt.X + resizeTo.W) - (p.X + S.W)
resizeTo.W -= delta
} else if drawAt.X < p.X {
// Hitting the left edge. Cap the X coord and shrink the width.
delta := p.X - drawAt.X // positive number
drawAt.X = p.X
// scrollTo.X -= delta // TODO
resizeTo.W -= delta
}
if drawAt.Y+resizeTo.H > p.Y+S.H {
// Hitting the bottom edge, shrink the height.
delta := (drawAt.Y + resizeTo.H) - (p.Y + S.H)
resizeTo.H -= delta
} else if drawAt.Y < p.Y {
// Hitting the top edge. Cap the Y coord and shrink the height.
delta := p.Y - drawAt.Y
drawAt.Y = p.Y
// scrollTo.Y -= delta // TODO
resizeTo.H -= delta
}
if resizeTo != actorSize {
can.Resize(resizeTo)
can.ScrollTo(scrollTo)
}
can.Present(e, drawAt)
// Clean up the canvas size and offset.
can.Resize(actorSize) // restore original size in case cropped
can.ScrollTo(render.Origin)
}
}