doodle/pkg/shell.go
Noah Petherbridge fb5a8a1ae8 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
2021-10-07 18:27:38 -07:00

400 lines
8.8 KiB
Go

package doodle
import (
"bytes"
"fmt"
"strings"
"git.kirsle.net/apps/doodle/pkg/balance"
"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"
"git.kirsle.net/go/ui"
"github.com/robertkrimen/otto"
)
// Flash a message to the user.
func (d *Doodle) Flash(template string, v ...interface{}) {
log.Warn(template, v...)
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
d.shell.callback = callback
d.shell.Open = true
}
// Shell implements the developer console in-game.
type Shell struct {
parent *Doodle
Open bool
Prompt string
Repl bool
callback func(string) // for prompt answers only
Text string
History []string
Output []string
Flashes []Flash
// Blinky cursor variables.
cursor byte // cursor symbol
cursorFlip uint64 // ticks until cursor flip
cursorRate uint64
// Paging through history variables.
historyPaging bool
historyIndex int
// JavaScript shell interpreter.
js *otto.Otto
}
// Flash holds a message to flash on screen.
type Flash struct {
Text string
Expires uint64 // tick that it expires
Color render.Color
}
// NewShell initializes the shell helper (the "Shellper").
func NewShell(d *Doodle) Shell {
s := Shell{
parent: d,
History: []string{},
Output: []string{},
Flashes: []Flash{},
Prompt: ">",
cursor: '_',
cursorRate: balance.ShellCursorBlinkRate,
js: otto.New(),
}
// Make the Doodle instance available to the shell.
bindings := map[string]interface{}{
"d": d,
"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) {
d.Flash(row)
}
return ""
},
"loadscreen": map[string]interface{}{
"Show": loadscreen.Show,
"ShowWithProgress": loadscreen.ShowWithProgress,
"Hide": loadscreen.Hide,
"IsActive": loadscreen.IsActive,
"SetProgress": loadscreen.SetProgress,
},
}
for name, v := range bindings {
err := s.js.Set(name, v)
if err != nil {
log.Error("Failed to make `%s` available to JS shell: %s", name, err)
}
}
return s
}
// Close the shell, resetting its internal state.
func (s *Shell) Close() {
log.Debug("Shell: closing shell")
s.Open = false
s.Repl = false
s.Prompt = ">"
s.callback = nil
s.Text = ""
s.historyPaging = false
s.historyIndex = 0
}
// Execute a command in the shell.
func (s *Shell) Execute(input string) {
command := s.Parse(input)
if command.Raw != "" {
s.Output = append(s.Output, s.Prompt+command.Raw)
s.History = append(s.History, command.Raw)
}
// Are we answering a Prompt?
if s.callback != nil {
log.Info("Invoking prompt callback:")
s.callback(command.Raw)
s.Close()
return
}
if command.Command == "clear" {
s.Output = []string{}
} else {
err := command.Run(s.parent)
if err != nil {
s.Write(err.Error())
}
}
// Reset the text buffer in the shell.
if s.Repl {
s.Text = "$ "
} else {
s.Text = ""
}
}
// Write a line of output text to the console.
func (s *Shell) Write(line string) {
s.Output = append(s.Output, line)
s.Flashes = append(s.Flashes, Flash{
Text: line,
Expires: shmem.Tick + balance.FlashTTL,
})
}
// 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)
if len(input) == 0 {
return Command{}
}
var (
inQuote bool
buffer = bytes.NewBuffer([]byte{})
words = []string{}
)
for i := 0; i < len(input); i++ {
char := input[i]
switch char {
case ' ':
if inQuote {
buffer.WriteByte(char)
continue
}
if word := buffer.String(); word != "" {
words = append(words, word)
buffer.Reset()
}
case '"':
if !inQuote {
// An opening quote character.
inQuote = true
} else {
// The closing quote.
inQuote = false
if word := buffer.String(); word != "" {
words = append(words, word)
buffer.Reset()
}
}
default:
buffer.WriteByte(char)
}
}
if remainder := buffer.String(); remainder != "" {
words = append(words, remainder)
}
return Command{
Raw: input,
Command: words[0],
Args: words[1:],
ArgsLiteral: strings.TrimSpace(input[len(words[0]):]),
}
}
// Draw the shell.
func (s *Shell) Draw(d *Doodle, ev *event.State) error {
// 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.Escape {
s.Close()
return nil
} else if keybind.Enter(ev) {
s.Execute(s.Text)
// Auto-close the console unless in REPL mode.
if !s.Repl {
s.Close()
}
return nil
} else if (ev.Up || ev.Down) && 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.
isUp := ev.Up
ev.Down = false
ev.Up = false
// 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 shmem.Tick > s.cursorFlip {
s.cursorFlip = shmem.Tick + s.cursorRate
if s.cursor == ' ' {
s.cursor = '_'
} else {
s.cursor = ' '
}
}
// Read a character from the keyboard.
for _, key := range ev.KeysDown(true) {
// Backspace?
if key == `\b` {
if len(s.Text) > 0 {
s.Text = s.Text[:len(s.Text)-1]
}
} else {
s.Text += key
}
// HACK: I wanted to do:
// ev.SetKeyDown(key, false)
// But, ev.KeysDown(shifted=true) returns letter keys
// like 'M' when the key we wanted to unset was 'm',
// or we got '$' when we want to unset '5'... so all
// shifted chars got duplicated 3+ times on key press!
// So, just reset ALL key press states to work around it:
ev.ResetKeyDown()
}
// How tall is the box?
boxHeight := (lineHeight * (balance.ShellHistoryLineCount + 1)) + balance.ShellPadding
// Draw the background color.
d.Engine.DrawBox(
balance.ShellBackgroundColor,
render.Rect{
X: 0,
Y: d.height - boxHeight,
W: d.width,
H: boxHeight,
},
)
// Draw the recent commands.
outputY := d.height - (lineHeight * 2)
for i := 0; i < balance.ShellHistoryLineCount; i++ {
if len(s.Output) > i {
line := s.Output[len(s.Output)-1-i]
d.Engine.DrawText(
render.Text{
FontFilename: balance.ShellFontFilename,
Text: line,
Size: balance.ShellFontSize,
Color: balance.ShellForegroundColor,
},
render.Point{
X: balance.ShellPadding,
Y: outputY,
},
)
}
outputY -= lineHeight
}
// Draw the command prompt.
d.Engine.DrawText(
render.Text{
FontFilename: balance.ShellFontFilename,
Text: s.Prompt + s.Text + string(s.cursor),
Size: balance.ShellFontSize,
Color: balance.ShellPromptColor,
},
render.Point{
X: balance.ShellPadding,
Y: d.height - balance.ShellFontSize - balance.ShellPadding,
},
)
} else if len(s.Flashes) > 0 {
// Otherwise, just draw flashed messages.
valid := false // Did we actually draw any?
outputY := d.height - (lineHeight * 2) - 16
for i := len(s.Flashes); i > 0; i-- {
flash := s.Flashes[i-1]
if shmem.Tick >= flash.Expires {
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(
text,
render.Point{
X: balance.ShellPadding + toolbarWidth,
Y: outputY,
},
)
outputY -= lineHeight
valid = true
}
// If we've exhausted all flashes, free up the memory.
if !valid {
s.Flashes = []Flash{}
}
}
return nil
}