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 <msg>"
  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
This commit is contained in:
Noah 2021-10-07 18:24:18 -07:00
parent 0b0af70a62
commit fb5a8a1ae8
13 changed files with 147 additions and 65 deletions

View File

@ -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.

View File

@ -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{

View File

@ -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":

View File

@ -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 <message>")
d.Flash("Flash a message back to the console")
case "error":
d.Flash("Usage: error <message>")
d.Flash("Flash an error message back to the console")
case "alert":
d.Flash("Usage: alert <message>")
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)

View File

@ -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)
})

View File

@ -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 = ""
}

View File

@ -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"?

View File

@ -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
}

View File

@ -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 {

View File

@ -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.

View File

@ -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
}

View File

@ -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

View File

@ -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,