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 0000000..b8a3d1a Binary files /dev/null and b/assets/wallpapers/notebook.png differ 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 5c29c0e..5281ee7 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) s.UI.Canvas.InstallActors(s.Level.Actors) } else if s.filename != "" && s.OpenFile { log.Debug("EditorScene.Setup: Loading map from filename at %s", s.filename) @@ -71,7 +73,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 } @@ -154,7 +156,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 aaa3ee7..1c62022 100644 --- a/play_scene.go +++ b/play_scene.go @@ -19,6 +19,7 @@ type PlayScene struct { Level *level.Level // Private variables. + d *Doodle drawing *uix.Canvas // Player character @@ -32,6 +33,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))) @@ -40,7 +42,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(s.d.Engine, s.Level) s.drawing.InstallActors(s.Level.Actors) } else if s.Filename != "" { log.Debug("PlayScene.Setup: loading map from file %s", s.Filename) @@ -53,7 +55,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.") @@ -161,7 +163,7 @@ func (s *PlayScene) LoadLevel(filename string) error { } s.Level = level - s.drawing.LoadLevel(s.Level) + s.drawing.LoadLevel(s.d.Engine, s.Level) s.drawing.InstallActors(s.Level.Actors) s.drawing.AddActor(s.Player) 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 c0737e2..9413a34 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. @@ -64,6 +73,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 { @@ -95,8 +105,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. @@ -262,6 +291,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 { @@ -298,6 +365,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 +}