Play: Autoscrolling and Bounded Level Support

Implement scrolling behavior in Play Mode by allowing the Canvas to
follow a specific actor and keep it in view. The Canvas has a
FollowActor property which holds an ID of the actor to follow (if blank,
no actor is being followed).

In Play Mode the Player is followed and when they get too close to the
left or right edges of the screen, the level will scroll to try and
catch them. If the player is moving very fast they can outrun the
camera.

The bounded levels are enforced in Play Mode and the camera won't scroll
to view pixels out-of-bounds and the Doodad actors inside the level
aren't allowed to exit its boundaries. This is global, not only for the
Player doodad but any Doodad that came with the level as well.

Other changes:

- Restructured Canvas widget code into many new files. The Canvas widget
  is shaping up to be where most of the magic happens, which is okay
  because it's close to the action and pulling the strings from outside
  would be harder, even tho as a UI element you think it should be
  lightweight.
- Debug Overlay: added room for Scenes to insert their own custom Debug
  Overlay key/value pairs (the values are string pointers so the Scene
  can update them freely):
  - The core labels are FPS, Scene and Mouse. The Pixel (world
    coordinate under cursor) is removed from the core labels.
  - Edit Scene provides Pixel, Tool and Swatch
  - Play Scene provides Pixel, Player, Viewport, Scroll
This commit is contained in:
Noah 2018-10-28 17:33:24 -07:00
parent 93bdaa0c43
commit 6713dd7bfc
15 changed files with 695 additions and 435 deletions

View File

@ -222,11 +222,14 @@ Probably mostly DRM free. Will want some sort of account server early-on though.
* The texture file will be a square (rectangular maybe ok) with four quadrants * The texture file will be a square (rectangular maybe ok) with four quadrants
from which the textures will be extracted. For example if the overall image from which the textures will be extracted. For example if the overall image
size was 100x100 pixels, it will be divided into the four 50x50 quadrants. size was 100x100 pixels, it will be divided into the four 50x50 quadrants.
1. `TL`: Top left corner is the top left edge of the "page" the level is on 1. `Corner`: Top left corner is the top left edge of the "page" the level is on
2. `TR`: Top right corner is the repeated "top of page" texture. 2. `Top`: Top right corner is the repeated "top of page" texture.
3. `BL`: Bottom left corner is the repeated "left of page" texture. 3. `Left`: Bottom left corner is the repeated "left of page" texture.
4. `BR`: Bottom right corner is the repeated background texture that extends 4. `Repeat`: Bottom right corner is the repeated background texture that extends
infinitely in all directions. infinitely in all directions.
* The Repeat texture is used all the time, and the other three are used when the
level type has boundaries (on the top and left edges in particular) to draw
decorative borders instead of the Repeat texture.
* Levels will be able to choose a "page type" which controls how the wallpaper * Levels will be able to choose a "page type" which controls how the wallpaper
will be drawn and how the level boundaries may be constrained. There will be will be drawn and how the level boundaries may be constrained. There will be
four options: four options:
@ -241,22 +244,14 @@ Probably mostly DRM free. Will want some sort of account server early-on though.
wall. wall.
3. **Bounded:** The map has a fixed width and height and is bounded on all 3. **Bounded:** The map has a fixed width and height and is bounded on all
four edges. four edges.
4. **Bounded, Mirrored Wallpaper:** same as Bounded but with a different 4. **Bordered:** same as Bounded but the right and bottom edges are decorated
wallpaper behavior. using mirrors of the top and left textures. This would look silly on notebook
* The page types will have their own behaviors with how wallpapers are drawn: paper (having an extra red line and blank margin) but would work for
* **Unbounded:** only the `BR` texture from the wallpaper is used, repeated wrap-around textures like paper placemats.
infinitely in the X and Y directions. The top-left, top, and left edge 5. **Full Page:** special level type that treats the wallpaper image as a
textures are not used. literal full page scan to be drawn on top of. The level is bounded by the
* **No Negative Space:** the `TL` texture is drawn at coordinate `(0,0)`. literal dimensions of the wallpaper image which is drawn once instead of
To its right, the `TR` texture is repeated forever in the X direction, and cut up and tiled as per the usual behavior.
along the left edge of the page, the `BL` texture is repeated in the Y
direction. The remaining whitespace on the page repeats the `BR` texture
infinitely.
* **Bounded:** same as No Negative Space.
* **Bounded, Mirrored Wallpaper:** same as No Negative Space, but all of the
_other_ corners and edges are textured too, with mirror images of the Top,
Top Left, and Left textures. This would look silly on the "ruled notebook"
texture, but could be useful to emborder the level with a fancy texture.
* The game will come with a few built-in textures for levels to refer to by * The game will come with a few built-in textures for levels to refer to by
name. These textures don't need to be distributed with the map files themselves, name. These textures don't need to be distributed with the map files themselves,
as every copy of the game should include these (or a sensible fallback would as every copy of the game should include these (or a sensible fallback would
@ -264,6 +259,24 @@ Probably mostly DRM free. Will want some sort of account server early-on though.
* The map author can also attach their own custom texture that will be included * The map author can also attach their own custom texture that will be included
inside the map file. inside the map file.
### Default Wallpapers
**notebook**: standard ruled notebook paper with a red line alone the Left
dge and a blank margin along the Top, with a Corner and the blue lines
aking up the Repeat in all directions.
![notebook.png](../assets/wallpapers/notebook.png)
**graph**: graph paper made up of a grid of light grey or blue lines.
**dots**: graph paper made of dots at the intersections but not the lines in
between.
**legal**: yellow lined notebook paper (legal pad).
**placemat**: a placemat texture with a wavy outline that emborders the map
on all four sides. To be used with the Bordered level type.
# Text Console # Text Console
* Create a rudimentary dev console for entering text commands in-game. It * Create a rudimentary dev console for entering text commands in-game. It

View File

@ -9,6 +9,15 @@ var (
// Speed to scroll a canvas with arrow keys in Edit Mode. // Speed to scroll a canvas with arrow keys in Edit Mode.
CanvasScrollSpeed int32 = 8 CanvasScrollSpeed int32 = 8
// Window scrolling behavior in Play Mode.
ScrollboxHoz = 64 // horizontal pixels close to canvas border
ScrollboxVert = 128
ScrollMaxVelocity = 24
// Player speeds
PlayerMaxVelocity = 12
Gravity = 2
// Default chunk size for canvases. // Default chunk size for canvases.
ChunkSize = 128 ChunkSize = 128

View File

@ -13,6 +13,7 @@ type Actor interface {
// Position and velocity, not saved to disk. // Position and velocity, not saved to disk.
Position() render.Point Position() render.Point
Velocity() render.Point Velocity() render.Point
SetVelocity(render.Point)
Size() render.Rect Size() render.Rect
Grounded() bool Grounded() bool
SetGrounded(bool) SetGrounded(bool)

View File

@ -44,6 +44,11 @@ func (d *Drawing) Velocity() render.Point {
return d.velocity return d.velocity
} }
// SetVelocity to set the speed.
func (d *Drawing) SetVelocity(v render.Point) {
d.velocity = v
}
// Size returns the Drawing's size. // Size returns the Drawing's size.
func (d *Drawing) Size() render.Rect { func (d *Drawing) Size() render.Rect {
return d.size return d.size

View File

@ -1,81 +0,0 @@
package doodads
import (
"git.kirsle.net/apps/doodle/render"
)
// PlayerID is the Doodad ID for the player character.
const PlayerID = "PLAYER"
// Player is a special doodad for the player character.
type Player struct {
point render.Point
velocity render.Point
size render.Rect
grounded bool
}
// NewPlayer creates the special Player Character doodad.
func NewPlayer() *Player {
return &Player{
point: render.Point{
X: 100,
Y: 100,
},
size: render.Rect{
W: 32,
H: 32,
},
}
}
// ID of the Player singleton.
func (p *Player) ID() string {
return PlayerID
}
// Position of the player.
func (p *Player) Position() render.Point {
return p.point
}
// MoveBy a relative delta position.
func (p *Player) MoveBy(by render.Point) {
p.point.X += by.X
p.point.Y += by.Y
}
// MoveTo an absolute position.
func (p *Player) MoveTo(to render.Point) {
p.point = to
}
// Velocity returns the player's current velocity.
func (p *Player) Velocity() render.Point {
return p.velocity
}
// Size returns the player's size.
func (p *Player) Size() render.Rect {
return p.size
}
// Grounded returns if the player is grounded.
func (p *Player) Grounded() bool {
return p.grounded
}
// SetGrounded sets if the player is grounded.
func (p *Player) SetGrounded(v bool) {
p.grounded = v
}
// Draw the player sprite.
func (p *Player) Draw(e render.Engine) {
e.DrawBox(render.RGBA(255, 255, 153, 255), render.Rect{
X: p.point.X,
Y: p.point.Y,
W: p.size.W,
H: p.size.H,
})
}

View File

@ -31,6 +31,11 @@ type EditorScene struct {
Level *level.Level Level *level.Level
Doodad *doodads.Doodad Doodad *doodads.Doodad
// Custom debug overlay labels.
debTool *string
debSwatch *string
debWorldIndex *string
// Last saved filename by the user. // Last saved filename by the user.
filename string filename string
} }
@ -42,6 +47,16 @@ func (s *EditorScene) Name() string {
// Setup the editor scene. // Setup the editor scene.
func (s *EditorScene) Setup(d *Doodle) error { func (s *EditorScene) Setup(d *Doodle) error {
// Debug overlay labels.
s.debTool = new(string)
s.debSwatch = new(string)
s.debWorldIndex = new(string)
customDebugLabels = []debugLabel{
{"Pixel:", s.debWorldIndex},
{"Tool:", s.debTool},
{"Swatch:", s.debSwatch},
}
// Initialize the user interface. It references the palette and such so it // Initialize the user interface. It references the palette and such so it
// must be initialized after those things. // must be initialized after those things.
s.d = d s.d = d
@ -107,6 +122,11 @@ func (s *EditorScene) Setup(d *Doodle) error {
// Loop the editor scene. // Loop the editor scene.
func (s *EditorScene) Loop(d *Doodle, ev *events.State) error { func (s *EditorScene) Loop(d *Doodle, ev *events.State) error {
// Update debug overlay labels.
*s.debTool = s.UI.Canvas.Tool.String()
*s.debSwatch = s.UI.Canvas.Palette.ActiveSwatch.Name
*s.debWorldIndex = s.UI.Canvas.WorldIndexAt(s.UI.cursor).String()
// Has the window been resized? // Has the window been resized?
if resized := ev.Resized.Read(); resized { if resized := ev.Resized.Read(); resized {
w, h := d.Engine.WindowSize() w, h := d.Engine.WindowSize()
@ -262,6 +282,5 @@ func (s *EditorScene) SaveDoodad(filename string) error {
// Destroy the scene. // Destroy the scene.
func (s *EditorScene) Destroy() error { func (s *EditorScene) Destroy() error {
debugWorldIndex = render.Origin
return nil return nil
} }

View File

@ -163,11 +163,10 @@ func (u *EditorUI) Loop(ev *events.State) error {
// Update status bar labels. // Update status bar labels.
{ {
debugWorldIndex = u.Canvas.WorldIndexAt(u.cursor)
u.StatusMouseText = fmt.Sprintf("Rel:(%d,%d) Abs:(%s)", u.StatusMouseText = fmt.Sprintf("Rel:(%d,%d) Abs:(%s)",
ev.CursorX.Now, ev.CursorX.Now,
ev.CursorY.Now, ev.CursorY.Now,
debugWorldIndex, *u.Scene.debWorldIndex,
) )
u.StatusPaletteText = fmt.Sprintf("%s Tool", u.StatusPaletteText = fmt.Sprintf("%s Tool",
u.Canvas.Tool, u.Canvas.Tool,

41
fps.go
View File

@ -34,12 +34,15 @@ var (
fpsSkipped uint32 fpsSkipped uint32
fpsInterval uint32 = 1000 fpsInterval uint32 = 1000
// XXX: some opt-in WorldIndex variables for the debug overlay. // Custom labels for scenes to add to the debug overlay view.
// This is the world pixel that the mouse cursor is over, customDebugLabels []debugLabel
// the Cursor + Scroll position of the canvas.
debugWorldIndex render.Point
) )
type debugLabel struct {
key string
variable *string
}
// DrawDebugOverlay draws the debug FPS text on the SDL canvas. // DrawDebugOverlay draws the debug FPS text on the SDL canvas.
func (d *Doodle) DrawDebugOverlay() { func (d *Doodle) DrawDebugOverlay() {
if !DebugOverlay { if !DebugOverlay {
@ -53,17 +56,43 @@ func (d *Doodle) DrawDebugOverlay() {
keys = []string{ keys = []string{
"FPS:", "FPS:",
"Scene:", "Scene:",
"Pixel:",
"Mouse:", "Mouse:",
} }
values = []string{ values = []string{
fmt.Sprintf("%d (skip: %dms)", fpsCurrent, fpsSkipped), fmt.Sprintf("%d (skip: %dms)", fpsCurrent, fpsSkipped),
d.Scene.Name(), d.Scene.Name(),
debugWorldIndex.String(),
fmt.Sprintf("%d,%d", d.event.CursorX.Now, d.event.CursorY.Now), fmt.Sprintf("%d,%d", d.event.CursorX.Now, d.event.CursorY.Now),
} }
) )
// Insert custom keys.
for _, custom := range customDebugLabels {
keys = append(keys, custom.key)
if custom.variable == nil {
values = append(values, "<nil>")
} else if len(*custom.variable) == 0 {
values = append(values, `""`)
} else {
values = append(values, *custom.variable)
}
}
// Find the longest key.
var longest int
for _, key := range keys {
if len(key) > longest {
longest = len(key)
}
}
// Space pad the keys to align them.
for i, key := range keys {
if len(key) < longest {
key = strings.Repeat(" ", longest-len(key)) + key
keys[i] = key
}
}
key := ui.NewLabel(ui.Label{ key := ui.NewLabel(ui.Label{
Text: strings.Join(keys, "\n"), Text: strings.Join(keys, "\n"),
Font: render.Text{ Font: render.Text{

View File

@ -28,5 +28,10 @@ const (
// - The wallpaper hoz mirrors Left along the X=Width plane // - The wallpaper hoz mirrors Left along the X=Width plane
// - The wallpaper vert mirrors Top along the Y=Width plane // - The wallpaper vert mirrors Top along the Y=Width plane
// - The wallpaper 180 rotates the Corner for opposite corners // - The wallpaper 180 rotates the Corner for opposite corners
Bordered Bordered // TODO: to be implemented
// FullPage treats the wallpaper image as a literal full scan of a page.
// - The level boundaries are fixed to the wallpaper image's dimensions.
// - Used to e.g. scan in real letterhead paper and draw a map on it.
FullPage // TODO: to be implemented
) )

View File

@ -2,6 +2,7 @@ package doodle
import ( import (
"fmt" "fmt"
"math"
"git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/doodads" "git.kirsle.net/apps/doodle/doodads"
@ -22,6 +23,12 @@ type PlayScene struct {
d *Doodle d *Doodle
drawing *uix.Canvas drawing *uix.Canvas
// Custom debug labels.
debPosition *string
debViewport *string
debScroll *string
debWorldIndex *string
// Player character // Player character
Player *uix.Actor Player *uix.Actor
} }
@ -34,6 +41,20 @@ func (s *PlayScene) Name() string {
// Setup the play scene. // Setup the play scene.
func (s *PlayScene) Setup(d *Doodle) error { func (s *PlayScene) Setup(d *Doodle) error {
s.d = d s.d = d
// Initialize debug bound variables.
s.debPosition = new(string)
s.debViewport = new(string)
s.debScroll = new(string)
s.debWorldIndex = new(string)
customDebugLabels = []debugLabel{
{"Pixel:", s.debWorldIndex},
{"Player:", s.debPosition},
{"Viewport:", s.debViewport},
{"Scroll:", s.debScroll},
}
// Initialize the drawing canvas.
s.drawing = uix.NewCanvas(balance.ChunkSize, false) s.drawing = uix.NewCanvas(balance.ChunkSize, false)
s.drawing.MoveTo(render.Origin) s.drawing.MoveTo(render.Origin)
s.drawing.Resize(render.NewRect(int32(d.width), int32(d.height))) s.drawing.Resize(render.NewRect(int32(d.width), int32(d.height)))
@ -49,15 +70,19 @@ func (s *PlayScene) Setup(d *Doodle) error {
s.LoadLevel(s.Filename) s.LoadLevel(s.Filename)
} }
player := dummy.NewPlayer()
s.Player = uix.NewActor(player.ID(), &level.Actor{}, player.Doodad)
if s.Level == nil { if s.Level == nil {
log.Debug("PlayScene.Setup: no grid given, initializing empty grid") log.Debug("PlayScene.Setup: no grid given, initializing empty grid")
s.Level = level.New() s.Level = level.New()
s.drawing.LoadLevel(d.Engine, s.Level) s.drawing.LoadLevel(d.Engine, s.Level)
s.drawing.InstallActors(s.Level.Actors)
} }
player := dummy.NewPlayer()
s.Player = uix.NewActor(player.ID(), &level.Actor{}, player.Doodad)
s.Player.MoveTo(render.NewPoint(128, 128))
s.drawing.AddActor(s.Player)
s.drawing.FollowActor = s.Player.ID()
d.Flash("Entered Play Mode. Press 'E' to edit this map.") d.Flash("Entered Play Mode. Press 'E' to edit this map.")
return nil return nil
@ -65,6 +90,12 @@ func (s *PlayScene) Setup(d *Doodle) error {
// Loop the editor scene. // Loop the editor scene.
func (s *PlayScene) Loop(d *Doodle, ev *events.State) error { func (s *PlayScene) Loop(d *Doodle, ev *events.State) error {
// Update debug overlay variables.
*s.debWorldIndex = s.drawing.WorldIndexAt(render.NewPoint(ev.CursorX.Now, ev.CursorY.Now)).String()
*s.debPosition = s.Player.Position().String()
*s.debViewport = s.drawing.Viewport().String()
*s.debScroll = s.drawing.Scroll.String()
// Has the window been resized? // Has the window been resized?
if resized := ev.Resized.Read(); resized { if resized := ev.Resized.Read(); resized {
w, h := d.Engine.WindowSize() w, h := d.Engine.WindowSize()
@ -86,8 +117,11 @@ func (s *PlayScene) Loop(d *Doodle, ev *events.State) error {
return nil return nil
} }
s.drawing.Loop(ev)
s.movePlayer(ev) s.movePlayer(ev)
if err := s.drawing.Loop(ev); err != nil {
log.Error("Drawing Loop: %s", err)
}
return nil return nil
} }
@ -118,20 +152,22 @@ func (s *PlayScene) Draw(d *Doodle) error {
// movePlayer updates the player's X,Y coordinate based on key pressed. // movePlayer updates the player's X,Y coordinate based on key pressed.
func (s *PlayScene) movePlayer(ev *events.State) { func (s *PlayScene) movePlayer(ev *events.State) {
delta := s.Player.Position() delta := s.Player.Position()
var playerSpeed int32 = 8 var playerSpeed = int32(balance.PlayerMaxVelocity)
var gravity int32 = 2 var gravity = int32(balance.Gravity)
var velocity render.Point
if ev.Down.Now { if ev.Down.Now {
delta.Y += playerSpeed velocity.Y = playerSpeed
} }
if ev.Left.Now { if ev.Left.Now {
delta.X -= playerSpeed velocity.X = -playerSpeed
} }
if ev.Right.Now { if ev.Right.Now {
delta.X += playerSpeed velocity.X = playerSpeed
} }
if ev.Up.Now { if ev.Up.Now {
delta.Y -= playerSpeed velocity.Y = -playerSpeed
} }
// Apply gravity. // Apply gravity.
@ -141,16 +177,24 @@ func (s *PlayScene) movePlayer(ev *events.State) {
if ok { if ok {
// Collision happened with world. // Collision happened with world.
} }
delta = info.MoveTo _ = info.MoveTo
// Apply gravity if not grounded. // Apply gravity if not grounded.
if !s.Player.Grounded() { if !s.Player.Grounded() {
// Gravity has to pipe through the collision checker, too, so it // Gravity has to pipe through the collision checker, too, so it
// can't give us a cheated downward boost. // can't give us a cheated downward boost.
delta.Y += gravity velocity.Y += gravity
} }
s.Player.MoveTo(delta) // Cap the max speed.
if int(math.Abs(float64(velocity.X))) > balance.PlayerMaxVelocity {
velocity.X = int32(balance.PlayerMaxVelocity)
}
if int(math.Abs(float64(velocity.Y))) > balance.PlayerMaxVelocity {
velocity.Y = int32(balance.PlayerMaxVelocity)
}
s.Player.SetVelocity(velocity)
} }
// LoadLevel loads a level from disk. // LoadLevel loads a level from disk.
@ -165,7 +209,6 @@ func (s *PlayScene) LoadLevel(filename string) error {
s.Level = level s.Level = level
s.drawing.LoadLevel(s.d.Engine, s.Level) s.drawing.LoadLevel(s.d.Engine, s.Level)
s.drawing.InstallActors(s.Level.Actors) s.drawing.InstallActors(s.Level.Actors)
s.drawing.AddActor(s.Player)
return nil return nil
} }

View File

@ -20,6 +20,9 @@ type Scene interface {
// Goto a scene. First it unloads the current scene. // Goto a scene. First it unloads the current scene.
func (d *Doodle) Goto(scene Scene) error { func (d *Doodle) Goto(scene Scene) error {
// Reset any custom debug overlay entries.
customDebugLabels = []debugLabel{}
// Teardown existing scene. // Teardown existing scene.
if d.Scene != nil { if d.Scene != nil {
d.Scene.Destroy() d.Scene.Destroy()

View File

@ -9,7 +9,6 @@ import (
"git.kirsle.net/apps/doodle/doodads" "git.kirsle.net/apps/doodle/doodads"
"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/pkg/wallpaper" "git.kirsle.net/apps/doodle/pkg/wallpaper"
"git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui" "git.kirsle.net/apps/doodle/ui"
@ -34,6 +33,9 @@ type Canvas struct {
// to remove the mask. // to remove the mask.
MaskColor render.Color MaskColor render.Color
// Actor ID to follow the camera on automatically, i.e. the main player.
FollowActor string
// Debug tools // Debug tools
// NoLimitScroll suppresses the scroll limit for bounded levels. // NoLimitScroll suppresses the scroll limit for bounded levels.
NoLimitScroll bool NoLimitScroll bool
@ -134,29 +136,6 @@ func (w *Canvas) LoadDoodad(d *doodads.Doodad) {
w.Load(d.Palette, d.Layers[0].Chunker) 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)
}
w.actors = append(w.actors, NewActor(id, actor, doodad))
}
return nil
}
// AddActor injects additional actors into the canvas, such as a Player doodad.
func (w *Canvas) AddActor(actor *Actor) error {
w.actors = append(w.actors, actor)
return nil
}
// SetSwatch changes the currently selected swatch for editing. // SetSwatch changes the currently selected swatch for editing.
func (w *Canvas) SetSwatch(s *level.Swatch) { func (w *Canvas) SetSwatch(s *level.Swatch) {
w.Palette.ActiveSwatch = s w.Palette.ActiveSwatch = s
@ -177,21 +156,54 @@ func (w *Canvas) setup() {
// 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
// the canvas, i.e. to edit it. // the canvas, i.e. to edit it.
func (w *Canvas) Loop(ev *events.State) error { func (w *Canvas) Loop(ev *events.State) error {
if w.Scrollable { // Process the arrow keys scrolling the level in Edit Mode.
// Arrow keys to scroll the view. // canvas_scrolling.go
scrollBy := render.Point{} w.loopEditorScroll(ev)
if ev.Right.Now { if err := w.loopFollowActor(ev); err != nil {
scrollBy.X -= balance.CanvasScrollSpeed log.Error("Follow actor: %s", err) // not fatal but nice to know
} else if ev.Left.Now { }
scrollBy.X += balance.CanvasScrollSpeed w.loopConstrainScroll()
// Move any actors.
for _, a := range w.actors {
if v := a.Velocity(); v != render.Origin {
// orig := a.Drawing.Position()
a.MoveBy(v)
// Keep them contained inside the level.
if w.wallpaper.pageType > level.Unbounded {
var (
orig = w.WorldIndexAt(a.Drawing.Position())
moveBy render.Point
size = a.Canvas.Size()
)
// Bound it on the top left edges.
if orig.X < 0 {
moveBy.X = -orig.X
}
if orig.Y < 0 {
moveBy.Y = -orig.Y
}
// Bound it on the right bottom edges. XXX: downcast from int64!
if w.wallpaper.maxWidth > 0 {
if int64(orig.X+size.W) > w.wallpaper.maxWidth {
var delta = int32(w.wallpaper.maxWidth - int64(orig.X+size.W))
moveBy.X = delta
}
}
if w.wallpaper.maxHeight > 0 {
if int64(orig.Y+size.H) > w.wallpaper.maxHeight {
var delta = int32(w.wallpaper.maxHeight - int64(orig.Y+size.H))
moveBy.Y = delta
}
}
if !moveBy.IsZero() {
a.MoveBy(moveBy)
} }
if ev.Down.Now {
scrollBy.Y -= balance.CanvasScrollSpeed
} else if ev.Up.Now {
scrollBy.Y += balance.CanvasScrollSpeed
} }
if !scrollBy.IsZero() {
w.ScrollBy(scrollBy)
} }
} }
@ -275,273 +287,3 @@ func (w *Canvas) ScrollBy(by render.Point) {
func (w *Canvas) Compute(e render.Engine) { func (w *Canvas) Compute(e render.Engine) {
} }
// Present the canvas.
func (w *Canvas) Present(e render.Engine, p render.Point) {
var (
S = w.Size()
Viewport = w.Viewport()
)
// w.MoveTo(p) // TODO: when uncommented the canvas will creep down the Workspace frame in EditorMode
w.DrawBox(e, p)
e.DrawBox(w.Background(), render.Rect{
X: p.X + w.BoxThickness(1),
Y: p.Y + w.BoxThickness(1),
W: S.W - w.BoxThickness(2),
H: S.H - w.BoxThickness(2),
})
// Constrain the scroll view if the level is bounded.
if w.Scrollable && !w.NoLimitScroll {
// Constrain the top and left edges.
if w.wallpaper.pageType > level.Unbounded {
if w.Scroll.X > 0 {
w.Scroll.X = 0
}
if w.Scroll.Y > 0 {
w.Scroll.Y = 0
}
}
// Constrain the bottom and right for limited world sizes.
if w.wallpaper.maxWidth > 0 && w.wallpaper.maxHeight > 0 {
var (
// TODO: downcast from int64!
mw = int32(w.wallpaper.maxWidth)
mh = int32(w.wallpaper.maxHeight)
)
if Viewport.W > mw {
delta := Viewport.W - mw
w.Scroll.X += delta
}
if Viewport.H > mh {
delta := Viewport.H - mh
w.Scroll.Y += delta
}
}
}
// Draw the wallpaper.
if w.wallpaper.Valid() {
err := w.PresentWallpaper(e, p)
if err != nil {
log.Error(err.Error())
}
}
// Get the chunks in the viewport and cache their textures.
for coord := range w.chunks.IterViewportChunks(Viewport) {
if chunk, ok := w.chunks.GetChunk(coord); ok {
var tex render.Texturer
if w.MaskColor != render.Invisible {
tex = chunk.TextureMasked(e, w.MaskColor)
} else {
tex = chunk.Texture(e)
}
src := render.Rect{
W: tex.Size().W,
H: tex.Size().H,
}
// If the source bitmap is already bigger than the Canvas widget
// into which it will render, cap the source width and height.
// This is especially useful for Doodad buttons because the drawing
// is bigger than the button.
if src.W > S.W {
src.W = S.W
}
if src.H > S.H {
src.H = S.H
}
dst := render.Rect{
X: p.X + w.Scroll.X + w.BoxThickness(1) + (coord.X * int32(chunk.Size)),
Y: p.Y + w.Scroll.Y + w.BoxThickness(1) + (coord.Y * int32(chunk.Size)),
// 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,
H: src.H,
}
// TODO: all this shit is in TrimBox(), make it DRY
// If the destination width will cause it to overflow the widget
// box, trim off the right edge of the destination rect.
//
// Keep in mind we're dealing with chunks here, and a chunk is
// a small part of the image. Example:
// - Canvas is 800x600 (S.W=800 S.H=600)
// - Chunk wants to render at 790,0 width 100,100 or whatever
// dst={790, 0, 100, 100}
// - Chunk box would exceed 800px width (X=790 + W=100 == 890)
// - Find the delta how much it exceeds as negative (800 - 890 == -90)
// - Lower the Source and Dest rects by that delta size so they
// stay proportional and don't scale or anything dumb.
if dst.X+src.W > p.X+S.W {
// NOTE: delta is a negative number,
// so it will subtract from the width.
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 := (p.Y + S.H - w.BoxThickness(1)) - (dst.H + dst.Y)
src.H += delta
dst.H += delta
}
// The same for the top left edge, so the drawings don't overlap
// menu bars or left side toolbars.
// - Canvas was placed 80px from the left of the screen.
// Canvas.MoveTo(80, 0)
// - A texture wants to draw at 60, 0 which would cause it to
// overlap 20 pixels into the left toolbar. It needs to be cropped.
// - The delta is: p.X=80 - dst.X=60 == 20
// - Set destination X to p.X to constrain it there: 20
// - Subtract the delta from destination W so we don't scale it.
// - Add 20 to X of the source: the left edge of source is not visible
if dst.X < p.X {
// NOTE: delta is a positive number,
// so it will add to the destination coordinates.
delta := p.X - dst.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 + 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)
}
}
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)
}
}

115
uix/canvas_actors.go Normal file
View File

@ -0,0 +1,115 @@
package uix
import (
"fmt"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/render"
)
// 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 {
doodad, err := doodads.LoadJSON(userdir.DoodadPath(actor.Filename))
if err != nil {
return fmt.Errorf("InstallActors: %s", err)
}
w.actors = append(w.actors, NewActor(id, actor, doodad))
}
return nil
}
// AddActor injects additional actors into the canvas, such as a Player doodad.
func (w *Canvas) AddActor(actor *Actor) error {
w.actors = append(w.actors, actor)
return nil
}
// drawActors is a subroutine of Present() that superimposes the actors on top
// of the level 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 i, a := range w.actors {
if a == nil {
log.Error("Canvas.drawActors: null actor at index %d (of %d actors)", i, len(w.actors))
continue
}
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)
}
}

171
uix/canvas_present.go Normal file
View File

@ -0,0 +1,171 @@
package uix
import (
"fmt"
"strings"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
// Present the canvas.
func (w *Canvas) Present(e render.Engine, p render.Point) {
var (
S = w.Size()
Viewport = w.Viewport()
)
// w.MoveTo(p) // TODO: when uncommented the canvas will creep down the Workspace frame in EditorMode
w.DrawBox(e, p)
e.DrawBox(w.Background(), render.Rect{
X: p.X + w.BoxThickness(1),
Y: p.Y + w.BoxThickness(1),
W: S.W - w.BoxThickness(2),
H: S.H - w.BoxThickness(2),
})
// Draw the wallpaper.
if w.wallpaper.Valid() {
err := w.PresentWallpaper(e, p)
if err != nil {
log.Error(err.Error())
}
}
// Get the chunks in the viewport and cache their textures.
for coord := range w.chunks.IterViewportChunks(Viewport) {
if chunk, ok := w.chunks.GetChunk(coord); ok {
var tex render.Texturer
if w.MaskColor != render.Invisible {
tex = chunk.TextureMasked(e, w.MaskColor)
} else {
tex = chunk.Texture(e)
}
src := render.Rect{
W: tex.Size().W,
H: tex.Size().H,
}
// If the source bitmap is already bigger than the Canvas widget
// into which it will render, cap the source width and height.
// This is especially useful for Doodad buttons because the drawing
// is bigger than the button.
if src.W > S.W {
src.W = S.W
}
if src.H > S.H {
src.H = S.H
}
dst := render.Rect{
X: p.X + w.Scroll.X + w.BoxThickness(1) + (coord.X * int32(chunk.Size)),
Y: p.Y + w.Scroll.Y + w.BoxThickness(1) + (coord.Y * int32(chunk.Size)),
// 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,
H: src.H,
}
// TODO: all this shit is in TrimBox(), make it DRY
// If the destination width will cause it to overflow the widget
// box, trim off the right edge of the destination rect.
//
// Keep in mind we're dealing with chunks here, and a chunk is
// a small part of the image. Example:
// - Canvas is 800x600 (S.W=800 S.H=600)
// - Chunk wants to render at 790,0 width 100,100 or whatever
// dst={790, 0, 100, 100}
// - Chunk box would exceed 800px width (X=790 + W=100 == 890)
// - Find the delta how much it exceeds as negative (800 - 890 == -90)
// - Lower the Source and Dest rects by that delta size so they
// stay proportional and don't scale or anything dumb.
if dst.X+src.W > p.X+S.W {
// NOTE: delta is a negative number,
// so it will subtract from the width.
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 := (p.Y + S.H - w.BoxThickness(1)) - (dst.H + dst.Y)
src.H += delta
dst.H += delta
}
// The same for the top left edge, so the drawings don't overlap
// menu bars or left side toolbars.
// - Canvas was placed 80px from the left of the screen.
// Canvas.MoveTo(80, 0)
// - A texture wants to draw at 60, 0 which would cause it to
// overlap 20 pixels into the left toolbar. It needs to be cropped.
// - The delta is: p.X=80 - dst.X=60 == 20
// - Set destination X to p.X to constrain it there: 20
// - Subtract the delta from destination W so we don't scale it.
// - Add 20 to X of the source: the left edge of source is not visible
if dst.X < p.X {
// NOTE: delta is a positive number,
// so it will add to the destination coordinates.
delta := p.X - dst.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 + 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)
}
}
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),
})
}
}

187
uix/canvas_scrolling.go Normal file
View File

@ -0,0 +1,187 @@
package uix
import (
"errors"
"fmt"
"git.kirsle.net/apps/doodle/balance"
"git.kirsle.net/apps/doodle/events"
"git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/render"
"git.kirsle.net/apps/doodle/ui"
)
/*
Loop() subroutine to scroll the canvas using arrow keys (for edit mode).
If w.Scrollable is false this function won't do anything.
Cursor keys will scroll the drawing by balance.CanvasScrollSpeed per tick.
If the level pageType is constrained, the scrollable viewport will be
constrained to fit the bounds of the level.
The debug boolean `NoLimitScroll=true` will override the bounded level scroll
restriction and allow scrolling into out-of-bounds areas of the level.
*/
func (w *Canvas) loopEditorScroll(ev *events.State) error {
if !w.Scrollable {
return errors.New("canvas not scrollable")
}
// Arrow keys to scroll the view.
scrollBy := render.Point{}
if ev.Right.Now {
scrollBy.X -= balance.CanvasScrollSpeed
} else if ev.Left.Now {
scrollBy.X += balance.CanvasScrollSpeed
}
if ev.Down.Now {
scrollBy.Y -= balance.CanvasScrollSpeed
} else if ev.Up.Now {
scrollBy.Y += balance.CanvasScrollSpeed
}
if !scrollBy.IsZero() {
w.ScrollBy(scrollBy)
}
return nil
}
/*
Loop() subroutine to constrain the scrolled view to within a bounded level.
*/
func (w *Canvas) loopConstrainScroll() error {
if w.NoLimitScroll {
return errors.New("NoLimitScroll enabled")
}
var capped bool
// Constrain the top and left edges.
if w.wallpaper.pageType > level.Unbounded {
if w.Scroll.X > 0 {
w.Scroll.X = 0
capped = true
}
if w.Scroll.Y > 0 {
w.Scroll.Y = 0
capped = true
}
}
// Constrain the bottom and right for limited world sizes.
if w.wallpaper.maxWidth+w.wallpaper.maxHeight > 0 {
var (
// TODO: downcast from int64!
mw = int32(w.wallpaper.maxWidth)
mh = int32(w.wallpaper.maxHeight)
Viewport = w.Viewport()
)
if Viewport.W > mw {
delta := Viewport.W - mw
w.Scroll.X += delta
capped = true
}
if Viewport.H > mh {
delta := Viewport.H - mh
w.Scroll.Y += delta
capped = true
}
}
if capped {
return errors.New("scroll limited by level constraint")
}
return nil
}
/*
Loop() subroutine for Play Mode to follow an actor in the camera's view.
Does nothing if w.FollowActor is an empty string. Set it to the ID of an Actor
to follow. If the actor exists, the Canvas will scroll to keep it on the
screen.
*/
func (w *Canvas) loopFollowActor(ev *events.State) error {
// Are we following an actor?
if w.FollowActor == "" {
return nil
}
var (
P = w.Point()
S = w.Size()
)
// Find the actor.
for _, actor := range w.actors {
if actor.ID() != w.FollowActor {
continue
}
actor.Canvas.SetBorderSize(2)
actor.Canvas.SetBorderColor(render.Yellow)
actor.Canvas.SetBorderStyle(ui.BorderSolid)
var (
APosition = actor.Position() // relative to screen
APoint = actor.Drawing.Position()
ASize = actor.Canvas.Size()
scrollBy render.Point
)
// Scroll left
if APosition.X-P.X <= int32(balance.ScrollboxHoz) {
var delta = APoint.X - P.X
if delta > int32(balance.ScrollMaxVelocity) {
delta = int32(balance.ScrollMaxVelocity)
}
if delta < 0 {
// constrain in case they're FAR OFF SCREEN so we don't flip back around
delta = -delta
}
scrollBy.X = delta
}
// Scroll right
if APosition.X >= S.W-ASize.W-int32(balance.ScrollboxHoz) {
var delta = S.W - ASize.W - int32(balance.ScrollboxHoz)
if delta > int32(balance.ScrollMaxVelocity) {
delta = int32(balance.ScrollMaxVelocity)
}
scrollBy.X = -delta
}
// Scroll up
if APosition.Y-P.Y <= int32(balance.ScrollboxVert) {
var delta = APoint.Y - P.Y
if delta > int32(balance.ScrollMaxVelocity) {
delta = int32(balance.ScrollMaxVelocity)
}
if delta < 0 {
delta = -delta
}
scrollBy.Y = delta
}
// Scroll down
if APosition.Y >= S.H-ASize.H-int32(balance.ScrollboxVert) {
var delta = S.H - ASize.H - int32(balance.ScrollboxVert)
if delta > int32(balance.ScrollMaxVelocity) {
delta = int32(balance.ScrollMaxVelocity)
}
scrollBy.Y = -delta
}
if scrollBy != render.Origin {
w.ScrollBy(scrollBy)
}
return nil
}
return fmt.Errorf("actor ID '%s' not found in level", w.FollowActor)
}