From bca848d5347ab964e3e3d40a00e120c368dd06ac Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 27 Oct 2018 22:22:13 -0700 Subject: [PATCH] Wallpapers and Bounded Levels Implement the Wallpaper system into the levels and the concept of Bounded and Unbounded levels. The first wallpaper image is notepad.png which looks like standard ruled notebook paper. On bounded levels, the top/left edges of the page look as you would expect and the blue lines tile indefinitely in the positive directions. On unbounded levels, you only get the repeating blue lines but not the edge pieces. A wallpaper is just a rectangular image file. The image is divided into four equal quadrants to be the Corner, Top, Left and Repeat textures for the wallpaper. The Repeat texture is ALWAYS used and fills all the empty space behind the drawing. (Doodads draw with blank canvases as before because only levels have wallpapers!) Levels have four options of a "Page Type": - Unbounded (default, infinite space) - NoNegativeSpace (has a top left edge but can grow infinitely) - Bounded (has a top left edge and bounded size) - Bordered (bounded with bordered texture; NOT IMPLEMENTED!) The scrollable viewport of a Canvas will respect the wallpaper and page type settings of a Level loaded into it. That is, if the level has a top left edge (not Unbounded) you can NOT scroll to see negative coordinates below (0,0) -- and if the level has a max dimension set, you can't scroll to see pixels outside those dimensions. The Canvas property NoLimitScroll=true will override the scroll locking and let you see outside the bounds, for debugging. - Default map settings for New Level are now: - Page Type: NoNegativeSpace - Wallpaper: notepad.png (default) - MaxWidth: 2550 (8.5" * 300 ppi) - MaxHeight: 3300 ( 11" * 300 ppi) --- .gitignore | 1 + assets/wallpapers/notebook.png | Bin 0 -> 4433 bytes balance/README.md | 4 + cmd/doodle/main.go | 2 + docs/Doodad Scripts.md | 66 ++++++++++++ editor_scene.go | 8 +- level/json.go | 5 + level/page_type.go | 32 ++++++ level/types.go | 16 +++ pkg/wallpaper/texture.go | 76 ++++++++++++++ pkg/wallpaper/wallpaper.go | 143 ++++++++++++++++++++++++++ pkg/wallpaper/wallpaper_test.go | 111 ++++++++++++++++++++ play_scene.go | 8 +- render/functions.go | 69 +++++++++++++ shell.go | 80 +++++++-------- uix/canvas.go | 71 ++++++++++++- uix/canvas_wallpaper.go | 175 ++++++++++++++++++++++++++++++++ 17 files changed, 820 insertions(+), 47 deletions(-) create mode 100644 assets/wallpapers/notebook.png create mode 100644 docs/Doodad Scripts.md create mode 100644 level/page_type.go create mode 100644 pkg/wallpaper/texture.go create mode 100644 pkg/wallpaper/wallpaper.go create mode 100644 pkg/wallpaper/wallpaper_test.go create mode 100644 render/functions.go create mode 100644 uix/canvas_wallpaper.go diff --git a/.gitignore b/.gitignore index 688cbc0..8fbafa8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ maps/ bin/ screenshot-*.png map-*.json +pkg/wallpaper/*.png diff --git a/assets/wallpapers/notebook.png b/assets/wallpapers/notebook.png new file mode 100644 index 0000000000000000000000000000000000000000..b8a3d1a0f73de9b4b3e7d4da5b65fcfbecceb301 GIT binary patch literal 4433 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 literal 0 HcmV?d00001 diff --git a/balance/README.md b/balance/README.md index c2373e2..27d897f 100644 --- a/balance/README.md +++ b/balance/README.md @@ -35,3 +35,7 @@ like `#FF00FF99` for 153 ($99) on the alpha channel. * `D_SCROLL_SPEED=8`: Canvas scroll speed when using the keyboard arrows in the Editor Mode, in pixels per tick. * `D_DOODAD_SIZE=100`: Default size when creating a new Doodad. + +Development booleans for unit tests (set to any non-empty value): + +* `T_WALLPAPER_PNG` for pkg/wallpaper to output PNG images. diff --git a/cmd/doodle/main.go b/cmd/doodle/main.go index 0ed83ec..531f91f 100644 --- a/cmd/doodle/main.go +++ b/cmd/doodle/main.go @@ -7,6 +7,8 @@ import ( "git.kirsle.net/apps/doodle" "git.kirsle.net/apps/doodle/balance" "git.kirsle.net/apps/doodle/render/sdl" + + _ "image/png" ) // Build number is the git commit hash. diff --git a/docs/Doodad Scripts.md b/docs/Doodad Scripts.md new file mode 100644 index 0000000..1a5271a --- /dev/null +++ b/docs/Doodad Scripts.md @@ -0,0 +1,66 @@ +# Doodad Scripting Engine + +Some ideas for the scripting engine for Doodads inside your level. + +# Architecture + +The script will be an "attached file" in the Doodad format as a special file +named "index.js" as the entry point. + +Each Doodad will have its `index.js` script loaded into an isolated JS +environment where it can't access any data about other Doodads or anything +user specific. The `main()` function is called so the Doodad script can +set itself up. + +The `main()` function should: + +* Initialize any state variables the Doodad wants to use in its script. +* Subscribe to callback events that the Doodad is interested in catching. + +The script interacts with the Doodle application through an API broker object +(a Go surface area of functions). + +# API Broker Interface + +```go +type API interface { + // "Self" functions. + SetFrame(frame int) // Set the currently visible frame in this Doodad. + MoveTo(render.Point) + + // Game functions.k + EndLevel() // Exit the current level with a victory + + /************************************ + * Event Handler Callback Functions * + ************************************/ + + // When we become visible on screen or disappear off the screen. + OnVisible() + OnHidden() + + // OnEnter: the other Doodad has ENTIRELY entered our box. Or if the other + // doodad is bigger, they have ENTIRELY enveloped ours. + OnEnter(func(other Doodad)) + + // OnCollide: when we bump into another Doodad. + OnCollide(func(other Doodad)) +} +``` + +## Mockup Script + +```javascript +function main() { + console.log("hello world"); + + // Register event callbacks. + Doodle.OnEnter(onEnter); +} + +// onEnter: handle when another Doodad (like the player) completely enters +// the bounding box of our Doodad. Example: a level exit. +function onEnter(other) { + +} +``` diff --git a/editor_scene.go b/editor_scene.go index f67f6b8..b909686 100644 --- a/editor_scene.go +++ b/editor_scene.go @@ -24,6 +24,7 @@ type EditorScene struct { DoodadSize int UI *EditorUI + d *Doodle // The current level or doodad object being edited, based on the // DrawingType. @@ -43,6 +44,7 @@ func (s *EditorScene) Name() string { func (s *EditorScene) Setup(d *Doodle) error { // Initialize the user interface. It references the palette and such so it // must be initialized after those things. + s.d = d s.UI = NewEditorUI(d, s) // Were we given configuration data? @@ -57,7 +59,7 @@ func (s *EditorScene) Setup(d *Doodle) error { case enum.LevelDrawing: if s.Level != nil { log.Debug("EditorScene.Setup: received level from scene caller") - s.UI.Canvas.LoadLevel(s.Level) + s.UI.Canvas.LoadLevel(d.Engine, s.Level) } else if s.filename != "" && s.OpenFile { log.Debug("EditorScene.Setup: Loading map from filename at %s", s.filename) if err := s.LoadLevel(s.filename); err != nil { @@ -70,7 +72,7 @@ func (s *EditorScene) Setup(d *Doodle) error { log.Debug("EditorScene.Setup: initializing a new Level") s.Level = level.New() s.Level.Palette = level.DefaultPalette() - s.UI.Canvas.LoadLevel(s.Level) + s.UI.Canvas.LoadLevel(d.Engine, s.Level) s.UI.Canvas.ScrollTo(render.Origin) s.UI.Canvas.Scrollable = true } @@ -153,7 +155,7 @@ func (s *EditorScene) LoadLevel(filename string) error { s.DrawingType = enum.LevelDrawing s.Level = level - s.UI.Canvas.LoadLevel(s.Level) + s.UI.Canvas.LoadLevel(s.d.Engine, s.Level) // TODO: debug for i, actor := range level.Actors { diff --git a/level/json.go b/level/json.go index d666e30..50a68d2 100644 --- a/level/json.go +++ b/level/json.go @@ -48,6 +48,11 @@ func LoadJSON(filename string) (*Level, error) { return m, fmt.Errorf("level.LoadJSON: JSON decode error: %s", err) } + // Fill in defaults. + if m.Wallpaper == "" { + m.Wallpaper = DefaultWallpaper + } + // Inflate the chunk metadata to map the pixels to their palette indexes. m.Chunker.Inflate(m.Palette) m.Actors.Inflate() diff --git a/level/page_type.go b/level/page_type.go new file mode 100644 index 0000000..668c0a6 --- /dev/null +++ b/level/page_type.go @@ -0,0 +1,32 @@ +package level + +// PageType configures the bounds and wallpaper behavior of a Level. +type PageType int + +// PageType values. +const ( + // Unbounded means the map can grow freely in any direction. + // - Only the repeat texture is used for the wallpaper. + Unbounded PageType = iota + + // NoNegativeSpace means the map is bounded at the top left edges. + // - Can't scroll or visit any pixels in negative X,Y coordinates. + // - Wallpaper shows the Corner at 0,0 + // - Wallpaper repeats the Top along the Y=0 plane + // - Wallpaper repeats the Left along the X=0 plane + // - The repeat texture fills the rest of the level. + NoNegativeSpace + + // Bounded is the same as NoNegativeSpace but the level is imposing a + // maximum cap on the width and height of the level. + // - Can't scroll below X,Y origin at 0,0 + // - Can't scroll past the bounded width and height of the level + Bounded + + // Bordered is like Bounded except the corner textures are wrapped + // around the other edges of the level too. + // - 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 +) diff --git a/level/types.go b/level/types.go index 276b06f..b603734 100644 --- a/level/types.go +++ b/level/types.go @@ -8,6 +8,11 @@ import ( "git.kirsle.net/apps/doodle/render" ) +// Useful variables. +var ( + DefaultWallpaper = "notebook.png" +) + // Base provides the common struct keys that are shared between Levels and // Doodads. type Base struct { @@ -33,6 +38,12 @@ type Level struct { // properties (solid, fire, slippery, etc.) Palette *Palette `json:"palette"` + // Page boundaries and wallpaper settings. + PageType PageType `json:"pageType"` + MaxWidth int64 `json:"boundedWidth"` // only if bounded or bordered + MaxHeight int64 `json:"boundedHeight"` + Wallpaper string `json:"wallpaper"` + // Actors keep a list of the doodad instances in this map. Actors ActorMap `json:"actors"` } @@ -46,6 +57,11 @@ func New() *Level { Chunker: NewChunker(balance.ChunkSize), Palette: &Palette{}, Actors: ActorMap{}, + + PageType: NoNegativeSpace, + Wallpaper: DefaultWallpaper, + MaxWidth: 2550, + MaxHeight: 3300, } } diff --git a/pkg/wallpaper/texture.go b/pkg/wallpaper/texture.go new file mode 100644 index 0000000..71a81ad --- /dev/null +++ b/pkg/wallpaper/texture.go @@ -0,0 +1,76 @@ +package wallpaper + +// The methods that deal in cached Textures for Doodle. + +import ( + "fmt" + "image" + "os" + + "git.kirsle.net/apps/doodle/pkg/userdir" + "git.kirsle.net/apps/doodle/render" + "golang.org/x/image/bmp" +) + +// CornerTexture returns the Texture. +func (wp *Wallpaper) CornerTexture(e render.Engine) (render.Texturer, error) { + fmt.Println("CornerTex") + if wp.tex.corner == nil { + tex, err := texture(e, wp.corner, wp.Name+"c") + wp.tex.corner = tex + return tex, err + } + return wp.tex.corner, nil +} + +// TopTexture returns the Texture. +func (wp *Wallpaper) TopTexture(e render.Engine) (render.Texturer, error) { + if wp.tex.top == nil { + tex, err := texture(e, wp.top, wp.Name+"t") + wp.tex.top = tex + return tex, err + } + return wp.tex.top, nil +} + +// LeftTexture returns the Texture. +func (wp *Wallpaper) LeftTexture(e render.Engine) (render.Texturer, error) { + if wp.tex.left == nil { + tex, err := texture(e, wp.left, wp.Name+"l") + wp.tex.left = tex + return tex, err + } + return wp.tex.left, nil +} + +// RepeatTexture returns the Texture. +func (wp *Wallpaper) RepeatTexture(e render.Engine) (render.Texturer, error) { + if wp.tex.repeat == nil { + tex, err := texture(e, wp.repeat, wp.Name+"x") + wp.tex.repeat = tex + return tex, err + } + return wp.tex.repeat, nil +} + +func texture(e render.Engine, img *image.RGBA, name string) (render.Texturer, error) { + filename := userdir.CacheFilename("wallpaper", name+".bmp") + if _, err := os.Stat(filename); os.IsNotExist(err) { + fh, err := os.Create(filename) + if err != nil { + return nil, fmt.Errorf("CornerTexture: %s", err.Error()) + } + defer fh.Close() + + err = bmp.Encode(fh, img) + if err != nil { + return nil, fmt.Errorf("CornerTexture: bmp.Encode: %s", err.Error()) + } + } + + texture, err := e.NewBitmap(filename) + if err != nil { + return nil, fmt.Errorf("CornerTexture: NewBitmap(%s): %s", filename, err.Error()) + } + return texture, nil +} diff --git a/pkg/wallpaper/wallpaper.go b/pkg/wallpaper/wallpaper.go new file mode 100644 index 0000000..05885d0 --- /dev/null +++ b/pkg/wallpaper/wallpaper.go @@ -0,0 +1,143 @@ +package wallpaper + +import ( + "image" + "image/draw" + "os" + "path/filepath" + "strings" + + "git.kirsle.net/apps/doodle/render" +) + +// Wallpaper is a repeatable background image to go behind levels. +type Wallpaper struct { + Name string + Format string // image file format + Image *image.RGBA + + // Parsed values. + quarterWidth int + quarterHeight int + + // The four parsed images. + corner *image.RGBA // Top Left corner + top *image.RGBA // Top repeating + left *image.RGBA // Left repeating + repeat *image.RGBA // Main repeating + + // Cached textures. + tex struct { + corner render.Texturer + top render.Texturer + left render.Texturer + repeat render.Texturer + } +} + +// FromImage creates a Wallpaper from an image.Image. +// If the renger.Engine is nil it will compute images but not pre-cache any +// textures yet. +func FromImage(e render.Engine, img *image.RGBA, name string) (*Wallpaper, error) { + wp := &Wallpaper{ + Name: name, + Image: img, + } + wp.cache(e) + return wp, nil +} + +// FromFile creates a Wallpaper from a file on disk. +// If the renger.Engine is nil it will compute images but not pre-cache any +// textures yet. +func FromFile(e render.Engine, filename string) (*Wallpaper, error) { + fh, err := os.Open(filename) + if err != nil { + return nil, err + } + + img, format, err := image.Decode(fh) + if err != nil { + return nil, err + } + + // Ugly hack: make it an image.RGBA because the thing we get tends to be + // an image.Paletted, UGH! + var b = img.Bounds() + rgba := image.NewRGBA(b) + for x := b.Min.X; x < b.Max.X; x++ { + for y := b.Min.Y; y < b.Max.Y; y++ { + rgba.Set(x, y, img.At(x, y)) + } + } + + wp := &Wallpaper{ + Name: strings.Split(filepath.Base(filename), ".")[0], + Format: format, + Image: rgba, + } + wp.cache(e) + return wp, nil +} + +// cache the bitmap images. +func (wp *Wallpaper) cache(e render.Engine) { + // Zero-bound the rect cuz an image.Rect doesn't necessarily contain 0,0 + var rect = wp.Image.Bounds() + if rect.Min.X < 0 { + rect.Max.X += rect.Min.X + rect.Min.X = 0 + } + if rect.Min.Y < 0 { + rect.Max.Y += rect.Min.Y + rect.Min.Y = 0 + } + + // Our quarter rect size. + wp.quarterWidth = int(float64((rect.Max.X - rect.Min.X) / 2)) + wp.quarterHeight = int(float64((rect.Max.Y - rect.Min.Y) / 2)) + quarter := image.Rect(0, 0, wp.quarterWidth, wp.quarterHeight) + + // Slice the image into the four corners. + slice := func(dx, dy int) *image.RGBA { + slice := image.NewRGBA(quarter) + draw.Draw( + slice, + image.Rect(0, 0, wp.quarterWidth, wp.quarterHeight), + wp.Image, + image.Point{dx, dy}, + draw.Over, + ) + return slice + } + wp.corner = slice(0, 0) + wp.top = slice(wp.quarterWidth, 0) + wp.left = slice(0, wp.quarterHeight) + wp.repeat = slice(wp.quarterWidth, wp.quarterHeight) + +} + +// QuarterSize returns the width and height of the quarter images. +func (wp *Wallpaper) QuarterSize() (int, int) { + return wp.quarterWidth, wp.quarterHeight +} + +// Corner returns the top left corner image. +func (wp *Wallpaper) Corner() *image.RGBA { + return wp.corner +} + +// Top returns the top repeating image. +func (wp *Wallpaper) Top() *image.RGBA { + return wp.top +} + +// Left returns the left repeating image. +func (wp *Wallpaper) Left() *image.RGBA { + return wp.left +} + +// Repeat returns the main repeating image. +func (wp *Wallpaper) Repeat() *image.RGBA { + return wp.repeat +} diff --git a/pkg/wallpaper/wallpaper_test.go b/pkg/wallpaper/wallpaper_test.go new file mode 100644 index 0000000..72d3c1e --- /dev/null +++ b/pkg/wallpaper/wallpaper_test.go @@ -0,0 +1,111 @@ +package wallpaper + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "os" + "testing" +) + +func TestWallpaper(t *testing.T) { + var testFunc = func(width, height int) { + var ( + qWidth = width / 2 + qHeight = height / 2 + red = color.RGBA{255, 0, 0, 255} + green = color.RGBA{0, 255, 0, 255} + blue = color.RGBA{0, 0, 255, 255} + pink = color.RGBA{255, 0, 255, 255} + ) + + // Create a dummy image that is width*height and has the four + // quadrants laid out as solid colors: + // Red | Green + // Blue | Pink + img := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.Draw( + // Corner: red + img, // dst Image + image.Rect(0, 0, qWidth, qHeight), // r Rectangle + image.NewUniform(red), // src Image + image.Point{0, 0}, // sp Point + draw.Over, // op Op + ) + draw.Draw( + // Top: green + img, + image.Rect(qWidth, 0, width, qHeight), + image.NewUniform(green), + image.Point{qWidth, 0}, + draw.Over, + ) + draw.Draw( + // Left: blue + img, + image.Rect(0, qHeight, qWidth, height), + image.NewUniform(blue), + image.Point{0, qHeight}, + draw.Over, + ) + draw.Draw( + // Repeat: pink + img, + image.Rect(qWidth, qHeight, width, height), + image.NewUniform(pink), + image.Point{qWidth, qHeight}, + draw.Over, + ) + + // Output as png to disk if you wanna see what's in it. + if os.Getenv("T_WALLPAPER_PNG") != "" { + fn := fmt.Sprintf("test-%dx%d.png", width, height) + if fh, err := os.Create(fn); err == nil { + defer fh.Close() + if err := png.Encode(fh, img); err != nil { + t.Errorf("err: %s", err) + } + } + } + + wp, err := FromImage(nil, img, "dummy") + if err != nil { + t.Errorf("Couldn't create FromImage: %s", err) + t.FailNow() + } + + // Check the quarter size is what we expected. + w, h := wp.QuarterSize() + if w != qWidth || h != qHeight { + t.Errorf( + "Got wrong quarter size: expected %dx%d but got %dx%d", + qWidth, qHeight, + w, h, + ) + } + + // Test the colors. + testColor := func(name string, img *image.RGBA, expect color.RGBA) { + if actual := img.At(5, 5); actual != expect { + t.Errorf( + "%s: expected color %v but got %v", + name, + expect, + actual, + ) + } + } + testColor("Corner", wp.Corner(), red) + testColor("Top", wp.Top(), green) + testColor("Left", wp.Left(), blue) + testColor("Repeat", wp.Repeat(), pink) + } + + testFunc(128, 128) + testFunc(128, 64) + testFunc(64, 128) + testFunc(12, 12) + testFunc(57, 39) +} diff --git a/play_scene.go b/play_scene.go index 659d70a..981e336 100644 --- a/play_scene.go +++ b/play_scene.go @@ -18,6 +18,7 @@ type PlayScene struct { Level *level.Level // Private variables. + d *Doodle drawing *uix.Canvas // Player character @@ -31,6 +32,7 @@ func (s *PlayScene) Name() string { // Setup the play scene. func (s *PlayScene) Setup(d *Doodle) error { + s.d = d s.drawing = uix.NewCanvas(balance.ChunkSize, false) s.drawing.MoveTo(render.Origin) s.drawing.Resize(render.NewRect(int32(d.width), int32(d.height))) @@ -39,7 +41,7 @@ func (s *PlayScene) Setup(d *Doodle) error { // Given a filename or map data to play? if s.Level != nil { log.Debug("PlayScene.Setup: received level from scene caller") - s.drawing.LoadLevel(s.Level) + s.drawing.LoadLevel(d.Engine, s.Level) } else if s.Filename != "" { log.Debug("PlayScene.Setup: loading map from file %s", s.Filename) s.LoadLevel(s.Filename) @@ -50,7 +52,7 @@ func (s *PlayScene) Setup(d *Doodle) error { if s.Level == nil { log.Debug("PlayScene.Setup: no grid given, initializing empty grid") s.Level = level.New() - s.drawing.LoadLevel(s.Level) + s.drawing.LoadLevel(d.Engine, s.Level) } d.Flash("Entered Play Mode. Press 'E' to edit this map.") @@ -140,7 +142,7 @@ func (s *PlayScene) LoadLevel(filename string) error { } s.Level = level - s.drawing.LoadLevel(s.Level) + s.drawing.LoadLevel(s.d.Engine, s.Level) return nil } diff --git a/render/functions.go b/render/functions.go new file mode 100644 index 0000000..b78a254 --- /dev/null +++ b/render/functions.go @@ -0,0 +1,69 @@ +package render + +// TrimBox helps with Engine.Copy() to trim a destination box so that it +// won't overflow with the parent container. +func TrimBox(src, dst *Rect, p Point, S Rect, thickness int32) { + // Constrain source width to not bigger than Canvas width. + if src.W > S.W { + src.W = S.W + } + if src.H > S.H { + src.H = S.H + } + + // 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 - thickness) - (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 - thickness) - (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 + thickness + dst.W -= delta + src.X += delta + } + if dst.Y < p.Y { + delta := p.Y - dst.Y + dst.Y = p.Y + thickness + dst.H -= delta + src.Y += delta + } + + // Trim the destination width so it doesn't overlap the Canvas border. + if dst.W >= S.W-thickness { + dst.W = S.W - thickness + } +} diff --git a/shell.go b/shell.go index 1ce49d5..23c828e 100644 --- a/shell.go +++ b/shell.go @@ -206,51 +206,51 @@ func (s *Shell) Parse(input string) Command { // Draw the shell. func (s *Shell) Draw(d *Doodle, ev *events.State) error { - if ev.EscapeKey.Read() { - s.Close() - return nil - } else if ev.EnterKey.Read() || ev.EscapeKey.Read() { - s.Execute(s.Text) - - // Auto-close the console unless in REPL mode. - if !s.Repl { - s.Close() - } - - return nil - } else if (ev.Up.Now || ev.Down.Now) && len(s.History) > 0 { - // Paging through history. - if !s.historyPaging { - s.historyPaging = true - s.historyIndex = len(s.History) - } - - // Consume the inputs and make convenient variables. - ev.Down.Read() - isUp := ev.Up.Read() - - // Scroll through the input history. - if isUp { - s.historyIndex-- - if s.historyIndex < 0 { - s.historyIndex = 0 - } - } else { - s.historyIndex++ - if s.historyIndex >= len(s.History) { - s.historyIndex = len(s.History) - 1 - } - } - - s.Text = s.History[s.historyIndex] - - } - // Compute the line height we can draw. lineHeight := balance.ShellFontSize + int(balance.ShellPadding) // If the console is open, draw the console. if s.Open { + if ev.EscapeKey.Read() { + s.Close() + return nil + } else if ev.EnterKey.Read() || ev.EscapeKey.Read() { + s.Execute(s.Text) + + // Auto-close the console unless in REPL mode. + if !s.Repl { + s.Close() + } + + return nil + } else if (ev.Up.Now || ev.Down.Now) && len(s.History) > 0 { + // Paging through history. + if !s.historyPaging { + s.historyPaging = true + s.historyIndex = len(s.History) + } + + // Consume the inputs and make convenient variables. + ev.Down.Read() + isUp := ev.Up.Read() + + // Scroll through the input history. + if isUp { + s.historyIndex-- + if s.historyIndex < 0 { + s.historyIndex = 0 + } + } else { + s.historyIndex++ + if s.historyIndex >= len(s.History) { + s.historyIndex = len(s.History) - 1 + } + } + + s.Text = s.History[s.historyIndex] + + } + // Cursor flip? if d.ticks > s.cursorFlip { s.cursorFlip = d.ticks + s.cursorRate diff --git a/uix/canvas.go b/uix/canvas.go index 58c5062..b0c11ca 100644 --- a/uix/canvas.go +++ b/uix/canvas.go @@ -2,6 +2,7 @@ package uix import ( "fmt" + "os" "strings" "git.kirsle.net/apps/doodle/balance" @@ -9,6 +10,7 @@ import ( "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" ) @@ -32,6 +34,10 @@ type Canvas struct { // to remove the mask. MaskColor render.Color + // Debug tools + // NoLimitScroll suppresses the scroll limit for bounded levels. + NoLimitScroll bool + // Underlying chunk data for the drawing. chunks *level.Chunker @@ -39,6 +45,9 @@ type Canvas struct { actor *level.Actor // if this canvas IS an actor actors []*Actor + // Wallpaper settings. + wallpaper *Wallpaper + // When the Canvas wants to delete Actors, but ultimately it is upstream // that controls the actors. Upstream should delete them and then reinstall // the actor list from scratch. @@ -70,6 +79,7 @@ func NewCanvas(size int, editable bool) *Canvas { Palette: level.NewPalette(), chunks: level.NewChunker(size), actors: make([]*Actor, 0), + wallpaper: &Wallpaper{}, } w.setup() w.IDFunc(func() string { @@ -101,8 +111,27 @@ func (w *Canvas) Load(p *level.Palette, g *level.Chunker) { } // LoadLevel initializes a Canvas from a Level object. -func (w *Canvas) LoadLevel(level *level.Level) { +func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) { w.Load(level.Palette, level.Chunker) + + // TODO: wallpaper paths + filename := "assets/wallpapers/" + level.Wallpaper + if _, err := os.Stat(filename); os.IsNotExist(err) { + log.Error("LoadLevel: %s", err) + filename = "assets/wallpapers/notebook.png" // XXX TODO + } + + wp, err := wallpaper.FromFile(e, filename) + if err != nil { + log.Error("wallpaper FromFile(%s): %s", filename, err) + } + + w.wallpaper.maxWidth = level.MaxWidth + w.wallpaper.maxHeight = level.MaxHeight + err = w.wallpaper.Load(e, level.PageType, wp) + if err != nil { + log.Error("wallpaper Load: %s", err) + } } // LoadDoodad initializes a Canvas from a Doodad object. @@ -274,6 +303,44 @@ func (w *Canvas) Present(e render.Engine, p render.Point) { 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 { @@ -310,6 +377,8 @@ func (w *Canvas) Present(e render.Engine, p render.Point) { 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. // diff --git a/uix/canvas_wallpaper.go b/uix/canvas_wallpaper.go new file mode 100644 index 0000000..827dadc --- /dev/null +++ b/uix/canvas_wallpaper.go @@ -0,0 +1,175 @@ +package uix + +import ( + "git.kirsle.net/apps/doodle/level" + "git.kirsle.net/apps/doodle/pkg/wallpaper" + "git.kirsle.net/apps/doodle/render" +) + +// Wallpaper configures the wallpaper in a Canvas. +type Wallpaper struct { + pageType level.PageType + maxWidth int64 + maxHeight int64 + corner render.Texturer + top render.Texturer + left render.Texturer + repeat render.Texturer +} + +// Valid returns whether the Wallpaper is configured. Only Levels should +// have wallpapers and Doodads will have nil ones. +func (wp *Wallpaper) Valid() bool { + return wp.repeat != nil +} + +// PresentWallpaper draws the wallpaper. +func (w *Canvas) PresentWallpaper(e render.Engine, p render.Point) error { + var ( + wp = w.wallpaper + S = w.Size() + size = wp.corner.Size() + Viewport = w.ViewportRelative() + origin = render.Point{ + X: p.X + w.Scroll.X + w.BoxThickness(1), + Y: p.Y + w.Scroll.Y + w.BoxThickness(1), + } + limit = render.Point{ + // NOTE: we add + the texture size so we would actually draw one + // full extra texture out-of-bounds for the repeating backgrounds. + // This is cuz for scrolling we offset the draw spot on a loop. + X: origin.X + S.W - w.BoxThickness(1) + size.W, + Y: origin.Y + S.H - w.BoxThickness(1) + size.H, + } + ) + + // For tiled textures, compute the offset amount. If we are scrolled away + // from the Origin (0,0) we find out by how far (subtract full tile sizes) + // and use the remainder as an offset for drawing the tiles. + var dx, dy int32 + if origin.X > 0 { + for origin.X > 0 && origin.X > size.W { + origin.X -= size.W + } + dx = origin.X + origin.X = 0 + } + if origin.Y > 0 { + for origin.Y > 0 && origin.Y > size.H { + origin.Y -= size.H + } + dy = origin.Y + origin.Y = 0 + } + + // And capping the scroll delta in the other direction. + if limit.X < S.W { + limit.X = S.W + } + if limit.Y < S.H { + // TODO: slight flicker on bottom edge when scrolling down + limit.Y = S.H + } + + // Tile the repeat texture. + for x := origin.X - size.W; x < limit.X; x += size.W { + for y := origin.Y - size.H; y < limit.Y; y += size.H { + src := render.Rect{ + W: size.W, + H: size.H, + } + dst := render.Rect{ + X: x + dx, + Y: y + dy, + W: src.W, + H: src.H, + } + + // Trim the edges of the destination box, like in canvas.go#Present + render.TrimBox(&src, &dst, p, S, w.BoxThickness(1)) + + e.Copy(wp.repeat, src, dst) + } + } + + // The left edge corner tiled along the left edge. + if wp.pageType > level.Unbounded { + for y := origin.Y; y < limit.Y; y += size.H { + src := render.Rect{ + W: size.W, + H: size.H, + } + dst := render.Rect{ + X: origin.X, + Y: y + dy, + W: src.W, + H: src.H, + } + render.TrimBox(&src, &dst, p, S, w.BoxThickness(1)) + e.Copy(wp.left, src, dst) + } + + // The top edge tiled along the top edge. + for x := origin.X; x < limit.X; x += size.W { + src := render.Rect{ + W: size.W, + H: size.H, + } + dst := render.Rect{ + X: x, + Y: origin.Y, + W: src.W, + H: src.H, + } + render.TrimBox(&src, &dst, p, S, w.BoxThickness(1)) + e.Copy(wp.top, src, dst) + } + + // The top left corner for all page types except Unbounded. + if Viewport.Intersects(size) { + src := render.Rect{ + W: size.W, + H: size.H, + } + dst := render.Rect{ + X: origin.X, + Y: origin.Y, + W: src.W, + H: src.H, + } + render.TrimBox(&src, &dst, p, S, w.BoxThickness(1)) + e.Copy(wp.corner, src, dst) + } + } + return nil +} + +// Load the wallpaper settings from a level. +func (wp *Wallpaper) Load(e render.Engine, pageType level.PageType, v *wallpaper.Wallpaper) error { + wp.pageType = pageType + if tex, err := v.CornerTexture(e); err == nil { + wp.corner = tex + } else { + return err + } + + if tex, err := v.TopTexture(e); err == nil { + wp.top = tex + } else { + return err + } + + if tex, err := v.LeftTexture(e); err == nil { + wp.left = tex + } else { + return err + } + + if tex, err := v.RepeatTexture(e); err == nil { + wp.repeat = tex + } else { + return err + } + + return nil +}