From 6713dd7bfc93c00604f3351bfaf147ea7ecbb234 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 28 Oct 2018 17:33:24 -0700 Subject: [PATCH] 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 --- Ideas.md | 53 +++--- balance/numbers.go | 9 + doodads/actor.go | 1 + doodads/drawing.go | 5 + doodads/player.go | 81 --------- editor_scene.go | 21 ++- editor_ui.go | 3 +- fps.go | 43 ++++- level/page_type.go | 7 +- play_scene.go | 71 ++++++-- scene.go | 3 + uix/canvas.go | 360 ++++++---------------------------------- uix/canvas_actors.go | 115 +++++++++++++ uix/canvas_present.go | 171 +++++++++++++++++++ uix/canvas_scrolling.go | 187 +++++++++++++++++++++ 15 files changed, 695 insertions(+), 435 deletions(-) delete mode 100644 doodads/player.go create mode 100644 uix/canvas_actors.go create mode 100644 uix/canvas_present.go create mode 100644 uix/canvas_scrolling.go diff --git a/Ideas.md b/Ideas.md index 3e4ca85..79c1b23 100644 --- a/Ideas.md +++ b/Ideas.md @@ -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 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. - 1. `TL`: 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. - 3. `BL`: Bottom left corner is the repeated "left of page" texture. - 4. `BR`: Bottom right corner is the repeated background texture that extends + 1. `Corner`: Top left corner is the top left edge of the "page" the level is on + 2. `Top`: Top right corner is the repeated "top of page" texture. + 3. `Left`: Bottom left corner is the repeated "left of page" texture. + 4. `Repeat`: Bottom right corner is the repeated background texture that extends 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 will be drawn and how the level boundaries may be constrained. There will be four options: @@ -241,22 +244,14 @@ Probably mostly DRM free. Will want some sort of account server early-on though. wall. 3. **Bounded:** The map has a fixed width and height and is bounded on all four edges. - 4. **Bounded, Mirrored Wallpaper:** same as Bounded but with a different - wallpaper behavior. -* The page types will have their own behaviors with how wallpapers are drawn: - * **Unbounded:** only the `BR` texture from the wallpaper is used, repeated - infinitely in the X and Y directions. The top-left, top, and left edge - textures are not used. - * **No Negative Space:** the `TL` texture is drawn at coordinate `(0,0)`. - To its right, the `TR` texture is repeated forever in the X direction, and - 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. + 4. **Bordered:** same as Bounded but the right and bottom edges are decorated + using mirrors of the top and left textures. This would look silly on notebook + paper (having an extra red line and blank margin) but would work for + wrap-around textures like paper placemats. + 5. **Full Page:** special level type that treats the wallpaper image as a + literal full page scan to be drawn on top of. The level is bounded by the + literal dimensions of the wallpaper image which is drawn once instead of + cut up and tiled as per the usual behavior. * 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, 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 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 * Create a rudimentary dev console for entering text commands in-game. It diff --git a/balance/numbers.go b/balance/numbers.go index 2149fca..7305b8f 100644 --- a/balance/numbers.go +++ b/balance/numbers.go @@ -9,6 +9,15 @@ var ( // Speed to scroll a canvas with arrow keys in Edit Mode. 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. ChunkSize = 128 diff --git a/doodads/actor.go b/doodads/actor.go index 9be2e1b..15f2191 100644 --- a/doodads/actor.go +++ b/doodads/actor.go @@ -13,6 +13,7 @@ type Actor interface { // Position and velocity, not saved to disk. Position() render.Point Velocity() render.Point + SetVelocity(render.Point) Size() render.Rect Grounded() bool SetGrounded(bool) diff --git a/doodads/drawing.go b/doodads/drawing.go index c333ccf..35c5c63 100644 --- a/doodads/drawing.go +++ b/doodads/drawing.go @@ -44,6 +44,11 @@ func (d *Drawing) Velocity() render.Point { return d.velocity } +// SetVelocity to set the speed. +func (d *Drawing) SetVelocity(v render.Point) { + d.velocity = v +} + // Size returns the Drawing's size. func (d *Drawing) Size() render.Rect { return d.size diff --git a/doodads/player.go b/doodads/player.go deleted file mode 100644 index 0b63c1c..0000000 --- a/doodads/player.go +++ /dev/null @@ -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, - }) -} diff --git a/editor_scene.go b/editor_scene.go index 5281ee7..f0c646f 100644 --- a/editor_scene.go +++ b/editor_scene.go @@ -31,6 +31,11 @@ type EditorScene struct { Level *level.Level Doodad *doodads.Doodad + // Custom debug overlay labels. + debTool *string + debSwatch *string + debWorldIndex *string + // Last saved filename by the user. filename string } @@ -42,6 +47,16 @@ func (s *EditorScene) Name() string { // Setup the editor scene. 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 // must be initialized after those things. s.d = d @@ -107,6 +122,11 @@ func (s *EditorScene) Setup(d *Doodle) error { // Loop the editor scene. 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? if resized := ev.Resized.Read(); resized { w, h := d.Engine.WindowSize() @@ -262,6 +282,5 @@ func (s *EditorScene) SaveDoodad(filename string) error { // Destroy the scene. func (s *EditorScene) Destroy() error { - debugWorldIndex = render.Origin return nil } diff --git a/editor_ui.go b/editor_ui.go index 0e0d6b5..784649f 100644 --- a/editor_ui.go +++ b/editor_ui.go @@ -163,11 +163,10 @@ func (u *EditorUI) Loop(ev *events.State) error { // Update status bar labels. { - debugWorldIndex = u.Canvas.WorldIndexAt(u.cursor) u.StatusMouseText = fmt.Sprintf("Rel:(%d,%d) Abs:(%s)", ev.CursorX.Now, ev.CursorY.Now, - debugWorldIndex, + *u.Scene.debWorldIndex, ) u.StatusPaletteText = fmt.Sprintf("%s Tool", u.Canvas.Tool, diff --git a/fps.go b/fps.go index 5321331..57f2193 100644 --- a/fps.go +++ b/fps.go @@ -34,12 +34,15 @@ var ( 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 + // Custom labels for scenes to add to the debug overlay view. + customDebugLabels []debugLabel ) +type debugLabel struct { + key string + variable *string +} + // DrawDebugOverlay draws the debug FPS text on the SDL canvas. func (d *Doodle) DrawDebugOverlay() { if !DebugOverlay { @@ -51,19 +54,45 @@ func (d *Doodle) DrawDebugOverlay() { Yoffset int32 = 20 // leave room for the menu bar Xoffset int32 = 5 keys = []string{ - " FPS:", + "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), } ) + // Insert custom keys. + for _, custom := range customDebugLabels { + keys = append(keys, custom.key) + if custom.variable == nil { + values = append(values, "") + } 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{ Text: strings.Join(keys, "\n"), Font: render.Text{ diff --git a/level/page_type.go b/level/page_type.go index 668c0a6..870f82c 100644 --- a/level/page_type.go +++ b/level/page_type.go @@ -28,5 +28,10 @@ const ( // - The wallpaper hoz mirrors Left along the X=Width plane // - The wallpaper vert mirrors Top along the Y=Width plane // - 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 ) diff --git a/play_scene.go b/play_scene.go index 1c62022..bf6546c 100644 --- a/play_scene.go +++ b/play_scene.go @@ -2,6 +2,7 @@ package doodle import ( "fmt" + "math" "git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/doodads" @@ -22,6 +23,12 @@ type PlayScene struct { d *Doodle drawing *uix.Canvas + // Custom debug labels. + debPosition *string + debViewport *string + debScroll *string + debWorldIndex *string + // Player character Player *uix.Actor } @@ -34,6 +41,20 @@ func (s *PlayScene) Name() string { // Setup the play scene. func (s *PlayScene) Setup(d *Doodle) error { 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.MoveTo(render.Origin) 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) } - player := dummy.NewPlayer() - s.Player = uix.NewActor(player.ID(), &level.Actor{}, player.Doodad) - if s.Level == nil { log.Debug("PlayScene.Setup: no grid given, initializing empty grid") s.Level = level.New() 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.") return nil @@ -65,6 +90,12 @@ func (s *PlayScene) Setup(d *Doodle) error { // Loop the editor scene. 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? if resized := ev.Resized.Read(); resized { w, h := d.Engine.WindowSize() @@ -86,8 +117,11 @@ func (s *PlayScene) Loop(d *Doodle, ev *events.State) error { return nil } - s.drawing.Loop(ev) s.movePlayer(ev) + if err := s.drawing.Loop(ev); err != nil { + log.Error("Drawing Loop: %s", err) + } + 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. func (s *PlayScene) movePlayer(ev *events.State) { delta := s.Player.Position() - var playerSpeed int32 = 8 - var gravity int32 = 2 + var playerSpeed = int32(balance.PlayerMaxVelocity) + var gravity = int32(balance.Gravity) + + var velocity render.Point if ev.Down.Now { - delta.Y += playerSpeed + velocity.Y = playerSpeed } if ev.Left.Now { - delta.X -= playerSpeed + velocity.X = -playerSpeed } if ev.Right.Now { - delta.X += playerSpeed + velocity.X = playerSpeed } if ev.Up.Now { - delta.Y -= playerSpeed + velocity.Y = -playerSpeed } // Apply gravity. @@ -141,16 +177,24 @@ func (s *PlayScene) movePlayer(ev *events.State) { if ok { // Collision happened with world. } - delta = info.MoveTo + _ = info.MoveTo // Apply gravity if not grounded. if !s.Player.Grounded() { // Gravity has to pipe through the collision checker, too, so it // 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. @@ -165,7 +209,6 @@ func (s *PlayScene) LoadLevel(filename string) error { s.Level = level s.drawing.LoadLevel(s.d.Engine, s.Level) s.drawing.InstallActors(s.Level.Actors) - s.drawing.AddActor(s.Player) return nil } diff --git a/scene.go b/scene.go index 1c04c43..3220661 100644 --- a/scene.go +++ b/scene.go @@ -20,6 +20,9 @@ type Scene interface { // Goto a scene. First it unloads the current scene. func (d *Doodle) Goto(scene Scene) error { + // Reset any custom debug overlay entries. + customDebugLabels = []debugLabel{} + // Teardown existing scene. if d.Scene != nil { d.Scene.Destroy() diff --git a/uix/canvas.go b/uix/canvas.go index 9413a34..430bb47 100644 --- a/uix/canvas.go +++ b/uix/canvas.go @@ -9,7 +9,6 @@ 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/pkg/wallpaper" "git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/ui" @@ -34,6 +33,9 @@ type Canvas struct { // to remove the mask. MaskColor render.Color + // Actor ID to follow the camera on automatically, i.e. the main player. + FollowActor string + // Debug tools // NoLimitScroll suppresses the scroll limit for bounded levels. NoLimitScroll bool @@ -134,29 +136,6 @@ 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) - } - - 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. func (w *Canvas) SetSwatch(s *level.Swatch) { 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 // the canvas, i.e. to edit it. func (w *Canvas) Loop(ev *events.State) error { - if w.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) + // Process the arrow keys scrolling the level in Edit Mode. + // canvas_scrolling.go + w.loopEditorScroll(ev) + if err := w.loopFollowActor(ev); err != nil { + log.Error("Follow actor: %s", err) // not fatal but nice to know + } + 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) + } + } } } @@ -275,273 +287,3 @@ func (w *Canvas) ScrollBy(by render.Point) { 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) - } -} diff --git a/uix/canvas_actors.go b/uix/canvas_actors.go new file mode 100644 index 0000000..457d207 --- /dev/null +++ b/uix/canvas_actors.go @@ -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) + } +} diff --git a/uix/canvas_present.go b/uix/canvas_present.go new file mode 100644 index 0000000..9e7dd38 --- /dev/null +++ b/uix/canvas_present.go @@ -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), + }) + } +} diff --git a/uix/canvas_scrolling.go b/uix/canvas_scrolling.go new file mode 100644 index 0000000..3c5f506 --- /dev/null +++ b/uix/canvas_scrolling.go @@ -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) +}