From fb5a8a1ae867387081ed5dd691df81b5eb515691 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 7 Oct 2021 18:24:18 -0700 Subject: [PATCH] Async Giant Screenshot, Player Physics and UI Polish * The "Giant Screenshot" feature takes a very long time, so it is made asynchronous. If you try and run a second one while the first is busy, you get an error flash. You can continue editing the level, even playtest it, or load a different level, and it will continue crunching on the Giant Screenshot and flash when it's finished. * Updated the player physics to use proper Velocity to jump off the ground rather than the hacky timer-based fixed speed approach. * FlashError() function to flash "error level" messages to the screen. They appear in orange text instead of the usual blue, and most error messages in the game use this now. The dev console "error " command can simulate an error message. * Flashed message fonts are updated. The blue font now uses softer stroke and shadow colors and the same algorithm applies to the orange error flashes. Some other changes to player physics: * Max velocity, acceleration speed, and gravity have been tweaked. * Fast turn-around if you are moving right and then need to go left. Your velocity resets to zero at the transition so you quickly get going the way you want to go. Some levels that need a bit of love for the new platforming physics: * Tutorial 3.level --- pkg/balance/numbers.go | 7 +-- pkg/balance/theme.go | 14 ++++++ pkg/cheats.go | 14 +++--- pkg/commands.go | 20 ++++++--- pkg/doodle.go | 4 +- pkg/editor_scene.go | 8 ++-- pkg/editor_ui_menubar.go | 28 +++++++----- pkg/editor_ui_popups.go | 6 +-- .../giant_screenshot/giant_screenshot.go | 28 ++++++++++-- pkg/menu_scene.go | 2 +- pkg/play_inventory.go | 2 +- pkg/play_scene.go | 45 ++++++++++++------- pkg/shell.go | 34 +++++++++++--- 13 files changed, 147 insertions(+), 65 deletions(-) diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index ad03a07..b63abbe 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -23,10 +23,11 @@ var ( } // Player speeds - PlayerMaxVelocity float64 = 6 - PlayerAcceleration float64 = 0.9 + PlayerMaxVelocity float64 = 7 + PlayerJumpVelocity float64 = -20 + PlayerAcceleration float64 = 0.12 Gravity float64 = 6 - GravityAcceleration float64 = 0.2 + GravityAcceleration float64 = 0.1 SlopeMaxHeight = 8 // max pixel height for player to walk up a slope // Default chunk size for canvases. diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index 3e3cb85..e741005 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -71,6 +71,20 @@ var ( WindowBackground = render.MustHexColor("#cdb689") WindowBorder = render.Grey + // Developer Shell and Flashed Messages styles. + FlashStrokeDarken = 60 + FlashShadowDarken = 120 + FlashFont = func(text string) render.Text { + return render.Text{ + Text: text, + Size: 18, + Color: render.SkyBlue, + Stroke: render.SkyBlue.Darken(FlashStrokeDarken), + Shadow: render.SkyBlue.Darken(FlashShadowDarken), + } + } + FlashErrorColor = render.MustHexColor("#FF9900") + // Menu bar styles. MenuBackground = render.Black MenuFont = render.Text{ diff --git a/pkg/cheats.go b/pkg/cheats.go index 5b3cf44..1d58d8c 100644 --- a/pkg/cheats.go +++ b/pkg/cheats.go @@ -28,7 +28,7 @@ func (c Command) cheatCommand(d *Doodle) bool { playScene.drawing.Editable = true d.Flash("Level canvas is now editable. Don't edit and drive!") } else { - d.Flash("Use this cheat in Play Mode to make the level canvas editable.") + d.FlashError("Use this cheat in Play Mode to make the level canvas editable.") } case "scroll scroll scroll your boat": @@ -36,7 +36,7 @@ func (c Command) cheatCommand(d *Doodle) bool { playScene.drawing.Scrollable = true d.Flash("Level canvas is now scrollable with the arrow keys.") } else { - d.Flash("Use this cheat in Play Mode to make the level scrollable.") + d.FlashError("Use this cheat in Play Mode to make the level scrollable.") } case "import antigravity": @@ -50,7 +50,7 @@ func (c Command) cheatCommand(d *Doodle) bool { d.Flash("Gravity restored for player character.") } } else { - d.Flash("Use this cheat in Play Mode to disable gravity for the player character.") + d.FlashError("Use this cheat in Play Mode to disable gravity for the player character.") } case "ghost mode": @@ -67,7 +67,7 @@ func (c Command) cheatCommand(d *Doodle) bool { d.Flash("Clipping and gravity restored for player character.") } } else { - d.Flash("Use this cheat in Play Mode to disable clipping for the player character.") + d.FlashError("Use this cheat in Play Mode to disable clipping for the player character.") } case "show all actors": @@ -77,7 +77,7 @@ func (c Command) cheatCommand(d *Doodle) bool { } d.Flash("All invisible actors made visible.") } else { - d.Flash("Use this cheat in Play Mode to show hidden actors, such as technical doodads.") + d.FlashError("Use this cheat in Play Mode to show hidden actors, such as technical doodads.") } case "give all keys": @@ -89,7 +89,7 @@ func (c Command) cheatCommand(d *Doodle) bool { playScene.Player.AddItem("small-key.doodad", 99) d.Flash("Given all keys to the player character.") } else { - d.Flash("Use this cheat in Play Mode to get all colored keys.") + d.FlashError("Use this cheat in Play Mode to get all colored keys.") } case "drop all items": @@ -97,7 +97,7 @@ func (c Command) cheatCommand(d *Doodle) bool { playScene.Player.ClearInventory() d.Flash("Cleared inventory of player character.") } else { - d.Flash("Use this cheat in Play Mode to clear your inventory.") + d.FlashError("Use this cheat in Play Mode to clear your inventory.") } case "fly like a bird": diff --git a/pkg/commands.go b/pkg/commands.go index 451d5d4..5b02de9 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -39,6 +39,9 @@ func (c Command) Run(d *Doodle) error { case "echo": d.Flash(c.ArgsLiteral) return nil + case "error": + d.FlashError(c.ArgsLiteral) + return nil case "alert": modal.Alert(c.ArgsLiteral) return nil @@ -104,13 +107,13 @@ func (c Command) Close(d *Doodle) error { // ExtractBindata dumps the app's embedded bindata to the filesystem. func (c Command) ExtractBindata(d *Doodle, path string) error { if len(path) == 0 || path[0] != '/' { - d.Flash("Required: an absolute path to a directory to extract to.") + d.FlashError("Required: an absolute path to a directory to extract to.") return nil } err := os.MkdirAll(path, 0755) if err != nil { - d.Flash("MkdirAll: %s", err) + d.FlashError("MkdirAll: %s", err) return err } @@ -120,7 +123,7 @@ func (c Command) ExtractBindata(d *Doodle, path string) error { data, err := assets.Asset(filename) if err != nil { - d.Flash("error on file %s: %s", filename, err) + d.FlashError("error on file %s: %s", filename, err) continue } @@ -131,7 +134,7 @@ func (c Command) ExtractBindata(d *Doodle, path string) error { fh, err := os.Create(outfile) if err != nil { - d.Flash("error writing file %s: %s", outfile, err) + d.FlashError("error writing file %s: %s", outfile, err) continue } fh.Write(data) @@ -145,7 +148,7 @@ func (c Command) ExtractBindata(d *Doodle, path string) error { // Help prints the help info. func (c Command) Help(d *Doodle) error { if len(c.Args) == 0 { - d.Flash("Available commands: new save edit play quit echo") + d.Flash("Available commands: new save edit play quit echo error") d.Flash(" alert clear help boolProp eval repl") d.Flash("Type `help` and then the command, like: `help edit`") return nil @@ -155,6 +158,9 @@ func (c Command) Help(d *Doodle) error { case "echo": d.Flash("Usage: echo ") d.Flash("Flash a message back to the console") + case "error": + d.Flash("Usage: error ") + d.Flash("Flash an error message back to the console") case "alert": d.Flash("Usage: alert ") d.Flash("Pop up an Alert box with a custom message") @@ -294,7 +300,7 @@ func (c Command) BoolProp(d *Doodle) error { } else { // Try the global boolProps in balance package. if err := balance.BoolProp(name, truthy); err != nil { - d.Flash("%s", err) + d.FlashError("%s", err) } else { d.Flash("%s: %+v", name, truthy) } @@ -307,7 +313,7 @@ func (c Command) BoolProp(d *Doodle) error { func (c Command) RunScript(d *Doodle, code interface{}) (otto.Value, error) { defer func() { if err := recover(); err != nil { - d.Flash("Panic: %s", err) + d.FlashError("Command.RunScript: Panic: %s", err) } }() out, err := d.shell.js.Run(code) diff --git a/pkg/doodle.go b/pkg/doodle.go index 3219bd1..63bbb4a 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -269,7 +269,7 @@ func (d *Doodle) NewDoodad(size int) { if answer != "" { i, err := strconv.Atoi(answer) if err != nil { - d.Flash("Error: Doodad size must be a number.") + d.FlashError("Error: Doodad size must be a number.") return } size = i @@ -277,7 +277,7 @@ func (d *Doodle) NewDoodad(size int) { // Recurse with the proper answer. if size <= 0 { - d.Flash("Error: Doodad size must be a positive number.") + d.FlashError("Error: Doodad size must be a positive number.") } d.NewDoodad(size) }) diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index 88a7e50..b6b1235 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -127,7 +127,7 @@ func (s *EditorScene) setupAsync(d *Doodle) error { "Opening: " + s.filename, ) if err := s.LoadLevel(s.filename); err != nil { - d.Flash("LoadLevel error: %s", err) + d.FlashError("LoadLevel error: %s", err) } else { s.UI.Canvas.InstallActors(s.Level.Actors) } @@ -138,7 +138,7 @@ func (s *EditorScene) setupAsync(d *Doodle) error { if usercfg.Current.WriteLockOverride { d.Flash("Note: write lock has been overridden") } else { - d.Flash("That level is write-protected and cannot be viewed in the editor.") + d.FlashError("That level is write-protected and cannot be viewed in the editor.") s.Level = nil s.UI.Canvas.ClearActors() s.filename = "" @@ -165,7 +165,7 @@ func (s *EditorScene) setupAsync(d *Doodle) error { if s.filename != "" && s.OpenFile { log.Debug("EditorScene.Setup: Loading doodad from filename at %s", s.filename) if err := s.LoadDoodad(s.filename); err != nil { - d.Flash("LoadDoodad error: %s", err) + d.FlashError("LoadDoodad error: %s", err) } } @@ -174,7 +174,7 @@ func (s *EditorScene) setupAsync(d *Doodle) error { if usercfg.Current.WriteLockOverride { d.Flash("Note: write lock has been overridden") } else { - d.Flash("That doodad is write-protected and cannot be viewed in the editor.") + d.FlashError("That doodad is write-protected and cannot be viewed in the editor.") s.Doodad = nil s.filename = "" } diff --git a/pkg/editor_ui_menubar.go b/pkg/editor_ui_menubar.go index 20a7cab..15d69ad 100644 --- a/pkg/editor_ui_menubar.go +++ b/pkg/editor_ui_menubar.go @@ -33,7 +33,7 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { drawingType = "level" saveFunc = func(filename string) { if err := u.Scene.SaveLevel(filename); err != nil { - d.Flash("Error: %s", err) + d.FlashError("Error: %s", err) } else { d.Flash("Saved level: %s", filename) } @@ -42,13 +42,13 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { drawingType = "doodad" saveFunc = func(filename string) { if err := u.Scene.SaveDoodad(filename); err != nil { - d.Flash("Error: %s", err) + d.FlashError("Error: %s", err) } else { d.Flash("Saved doodad: %s", filename) } } default: - d.Flash("Error: Scene.DrawingType is not a valid type") + d.FlashError("Error: Scene.DrawingType is not a valid type") } //////// @@ -127,13 +127,17 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { levelMenu.AddSeparator() levelMenu.AddItem("Giant Screenshot", func() { - filename, err := giant_screenshot.SaveGiantScreenshot(u.Scene.Level) - if err != nil { - d.Flash(err.Error()) - return - } + // It takes a LONG TIME to render for medium+ maps. + // Do so on a background thread. + go func() { + filename, err := giant_screenshot.SaveGiantScreenshot(u.Scene.Level) + if err != nil { + d.FlashError("Error: %s", err.Error()) + return + } - d.Flash("Saved screenshot to: %s", filename) + d.FlashError("Giant screenshot saved as: %s", filename) + }() }) levelMenu.AddItem("Open screenshot folder", func() { native.OpenLocalURL(userdir.ScreenshotDirectory) @@ -326,7 +330,7 @@ func (s *EditorScene) MenuSave(as bool) func() { // drawingType = "level" saveFunc = func(filename string) { if err := s.SaveLevel(filename); err != nil { - s.d.Flash("Error: %s", err) + s.d.FlashError("Error: %s", err) } else { s.d.Flash("Saved level: %s", filename) } @@ -335,13 +339,13 @@ func (s *EditorScene) MenuSave(as bool) func() { // drawingType = "doodad" saveFunc = func(filename string) { if err := s.SaveDoodad(filename); err != nil { - s.d.Flash("Error: %s", err) + s.d.FlashError("Error: %s", err) } else { s.d.Flash("Saved doodad: %s", filename) } } default: - s.d.Flash("Error: Scene.DrawingType is not a valid type") + s.d.FlashError("Error: Scene.DrawingType is not a valid type") } // "Save As"? diff --git a/pkg/editor_ui_popups.go b/pkg/editor_ui_popups.go index 82cf78e..db8c4cf 100644 --- a/pkg/editor_ui_popups.go +++ b/pkg/editor_ui_popups.go @@ -182,7 +182,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) { u.licenseWindow.Show() u.Supervisor.FocusWindow(u.licenseWindow) } - d.Flash("Level Publishing is only available in the full version of the game.") + d.FlashError("Level Publishing is only available in the full version of the game.") // modal.Alert( // "This feature is only available in the full version of the game.", // ).WithTitle("Please register") @@ -193,7 +193,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) { cwd, _ := os.Getwd() d.Prompt(fmt.Sprintf("File name (relative to %s)> ", cwd), func(answer string) { if answer == "" { - d.Flash("A file name is required to publish this level.") + d.FlashError("A file name is required to publish this level.") return } @@ -364,7 +364,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) { }, OnChangeLayer: func(index int) { if index < 0 || index >= len(scene.Doodad.Layers) { - d.Flash("OnChangeLayer: layer %d out of range", index) + d.FlashError("OnChangeLayer: layer %d out of range", index) return } diff --git a/pkg/level/giant_screenshot/giant_screenshot.go b/pkg/level/giant_screenshot/giant_screenshot.go index f950573..51483d2 100644 --- a/pkg/level/giant_screenshot/giant_screenshot.go +++ b/pkg/level/giant_screenshot/giant_screenshot.go @@ -1,6 +1,7 @@ package giant_screenshot import ( + "errors" "image" "image/draw" "image/png" @@ -11,6 +12,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/userdir" "git.kirsle.net/apps/doodle/pkg/wallpaper" "git.kirsle.net/go/render" @@ -20,8 +22,25 @@ import ( Giant Screenshot functionality for the Level Editor. */ +var locked bool + // GiantScreenshot returns a rendered RGBA image of the entire level. -func GiantScreenshot(lvl *level.Level) image.Image { +// +// Only one thread should be doing this at a time. A sync.Mutex will cause +// an error to return if another goroutine is already in the process of +// generating a screenshot, and you'll have to wait and try later. +func GiantScreenshot(lvl *level.Level) (image.Image, error) { + // Lock this to one user at a time. + if locked { + return nil, errors.New("a giant screenshot is still being processed; try later...") + } + locked = true + defer func() { + locked = false + }() + + shmem.Flash("Saving a giant screenshot (this takes a moment)...") + // How big will our image be? var ( size = lvl.Chunker.WorldSizePositive() @@ -110,7 +129,7 @@ func GiantScreenshot(lvl *level.Level) image.Image { } - return img + return img, nil } // SaveGiantScreenshot will take a screenshot and write it to a file on disk, @@ -118,7 +137,10 @@ func GiantScreenshot(lvl *level.Level) image.Image { func SaveGiantScreenshot(level *level.Level) (string, error) { var filename = time.Now().Format("2006-01-02_15-04-05.png") - img := GiantScreenshot(level) + img, err := GiantScreenshot(level) + if err != nil { + return "", err + } fh, err := os.Create(filepath.Join(userdir.ScreenshotDirectory, filename)) if err != nil { diff --git a/pkg/menu_scene.go b/pkg/menu_scene.go index b2d8516..cfba6b2 100644 --- a/pkg/menu_scene.go +++ b/pkg/menu_scene.go @@ -117,7 +117,7 @@ func (s *MenuScene) Setup(d *Doodle) error { return err } default: - d.Flash("No Valid StartupMenu Given to MenuScene") + d.FlashError("No Valid StartupMenu Given to MenuScene") } // Whatever window we got, give it window manager controls under Supervisor. diff --git a/pkg/play_inventory.go b/pkg/play_inventory.go index 8d69245..77243ee 100644 --- a/pkg/play_inventory.go +++ b/pkg/play_inventory.go @@ -65,7 +65,7 @@ func (s *PlayScene) computeInventory() { // Cache miss. Load the doodad here. doodad, err := doodads.LoadFile(filename) if err != nil { - s.d.Flash("Inventory item '%s' error: %s", filename, err) + s.d.FlashError("Inventory item '%s' error: %s", filename, err) continue } diff --git a/pkg/play_scene.go b/pkg/play_scene.go index 3c14b24..6f469c2 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -49,12 +49,13 @@ type PlayScene struct { debWorldIndex *string // Player character - Player *uix.Actor - playerPhysics *physics.Mover - lastCheckpoint render.Point - antigravity bool // Cheat: disable player gravity - noclip bool // Cheat: disable player clipping - playerJumpCounter int // limit jump length + Player *uix.Actor + playerPhysics *physics.Mover + lastCheckpoint render.Point + playerLastDirection float64 // player's heading last tick + antigravity bool // Cheat: disable player gravity + noclip bool // Cheat: disable player clipping + playerJumpCounter int // limit jump length // Inventory HUD. Impl. in play_inventory.go invenFrame *ui.Frame @@ -200,7 +201,7 @@ func (s *PlayScene) setupAsync(d *Doodle) error { if s.CanEdit { d.Flash("Entered Play Mode. Press 'E' to edit this map.") } else { - d.Flash("%s", s.Level.Title) + d.FlashError("%s", s.Level.Title) } // Pre-cache all bitmap images from the level chunks. @@ -272,9 +273,9 @@ func (s *PlayScene) setupPlayer() { // Surface warnings around the spawn flag. if flagCount == 0 { - s.d.Flash("Warning: this level contained no Start Flag.") + s.d.FlashError("Warning: this level contained no Start Flag.") } else if flagCount > 1 { - s.d.Flash("Warning: this level contains multiple Start Flags. Player spawn point is ambiguous.") + s.d.FlashError("Warning: this level contains multiple Start Flags. Player spawn point is ambiguous.") } s.Player = uix.NewActor("PLAYER", &level.Actor{}, player) @@ -340,7 +341,7 @@ func (s *PlayScene) BeatLevel() { // FailLevel handles a level failure triggered by a doodad. func (s *PlayScene) FailLevel(message string) { - s.d.Flash(message) + s.d.FlashError(message) s.ShowEndLevelModal( false, "You've died!", @@ -521,17 +522,29 @@ func (s *PlayScene) movePlayer(ev *event.State) { } // Up button to signal they want to jump. - if keybind.Up(ev) && (s.Player.Grounded() || s.playerJumpCounter >= 0) { - jumping = true - + if keybind.Up(ev) { if s.Player.Grounded() { - // Allow them to sustain the jump this many ticks. - s.playerJumpCounter = 32 + velocity.Y = balance.PlayerJumpVelocity } + } else if velocity.Y < 0 { + velocity.Y = 0 } + // if keybind.Up(ev) && (s.Player.Grounded() || s.playerJumpCounter >= 0) { + // jumping = true + + // if s.Player.Grounded() { + // // Allow them to sustain the jump this many ticks. + // s.playerJumpCounter = 32 + // } + // } // Moving left or right? Interpolate their velocity by acceleration. if direction != 0 { + if s.playerLastDirection != direction { + log.Error("Changed directions!") + velocity.X = 0 + } + // TODO: fast turn-around if they change directions so they don't // slip and slide while their velocity updates. velocity.X = physics.Lerp( @@ -560,6 +573,8 @@ func (s *PlayScene) movePlayer(ev *event.State) { } } + s.playerLastDirection = direction + // Move the player unless frozen. // TODO: if Y=0 then gravity fails, but not doing this allows the // player to jump while frozen. Not a HUGE deal right now as only Warp Doors diff --git a/pkg/shell.go b/pkg/shell.go index e17a2e5..6ea0dbc 100644 --- a/pkg/shell.go +++ b/pkg/shell.go @@ -9,6 +9,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/keybind" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/modal/loadscreen" + "git.kirsle.net/apps/doodle/pkg/physics" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/go/render" "git.kirsle.net/go/render/event" @@ -22,6 +23,12 @@ func (d *Doodle) Flash(template string, v ...interface{}) { d.shell.Write(fmt.Sprintf(template, v...)) } +// FlashError flashes an error-colored message to the user. +func (d *Doodle) FlashError(template string, v ...interface{}) { + log.Error(template, v...) + d.shell.WriteColorful(fmt.Sprintf(template, v...), balance.FlashErrorColor) +} + // Prompt the user for a question in the dev console. func (d *Doodle) Prompt(question string, callback func(string)) { d.shell.Prompt = question @@ -59,6 +66,7 @@ type Shell struct { type Flash struct { Text string Expires uint64 // tick that it expires + Color render.Color } // NewShell initializes the shell helper (the "Shellper"). @@ -80,6 +88,7 @@ func NewShell(d *Doodle) Shell { "Execute": s.Execute, "RGBA": render.RGBA, "Point": render.NewPoint, + "Vector": physics.NewVector, "Rect": render.NewRect, "Tree": func(w ui.Widget) string { for _, row := range ui.WidgetTree(w) { @@ -160,6 +169,16 @@ func (s *Shell) Write(line string) { }) } +// WriteError writes a line of error (red) text to the console. +func (s *Shell) WriteColorful(line string, color render.Color) { + s.Output = append(s.Output, line) + s.Flashes = append(s.Flashes, Flash{ + Text: line, + Color: color, + Expires: shmem.Tick + balance.FlashTTL, + }) +} + // Parse the command line. func (s *Shell) Parse(input string) Command { input = strings.TrimSpace(input) @@ -352,14 +371,15 @@ func (s *Shell) Draw(d *Doodle, ev *event.State) error { continue } + var text = balance.FlashFont(flash.Text) + if !flash.Color.IsZero() { + text.Color = flash.Color + text.Stroke = text.Color.Darken(balance.FlashStrokeDarken) + text.Shadow = text.Color.Darken(balance.FlashShadowDarken) + } + d.Engine.DrawText( - render.Text{ - Text: flash.Text, - Size: balance.ShellFontSize, - Color: render.SkyBlue, - Stroke: render.Grey, - Shadow: render.Black, - }, + text, render.Point{ X: balance.ShellPadding + toolbarWidth, Y: outputY,