doodle/pkg/scripting/exceptions/exceptions.go
Noah Petherbridge 6def8f7625 Walk up slopes smoothly, texture freeing improvement
* Fix collision detection to allow actors to walk up slopes smoothly, without
  losing any horizontal velocity.
* Fix scrolling a level canvas so that chunks near the right or bottom edge
  of the viewpoint were getting culled prematurely.
* Centralize JavaScript exception catching logic to attach Go and JS stack
  traces where possible to be more useful for debugging.
* Performance: flush all SDL2 textures from memory between scene transitions
  in the app. Also add a `flush-textures` dev console command to flush the
  textures at any time - they all should regenerate if still needed based on
  underlying go.Images which can be garbage collected.
2024-02-07 22:14:48 -08:00

297 lines
6.9 KiB
Go

// Package exceptions handles JavaScript errors nicely for the game.
package exceptions
import (
"fmt"
"strings"
"sync"
"git.kirsle.net/SketchyMaze/doodle/lib/debugging"
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/native"
"git.kirsle.net/SketchyMaze/doodle/pkg/shmem"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
"github.com/dop251/goja"
)
// The exception catching window is a singleton and appears on top with
// its own supervisor apart from whatever the game is currently doing.
var (
Supervisor *ui.Supervisor
Window *ui.Window
Disabled bool // don't reopen the window again
lastException string // text of last exception
excLabel *string // trimmed exception label text
mu sync.RWMutex // thread safety
// Configurables.
winSize = render.NewRect(380, 260)
excSize = render.NewRect(360, 140)
charsWide = 50 // to trim the exception text onto screen
charsTall = 9
)
func init() {
l := "No Exception Traceback"
excLabel = &l
}
// Catch a JavaScript exception and maybe show it to the user.
func Catch(exc string, args ...interface{}) {
if len(args) > 0 {
exc = fmt.Sprintf(exc, args...)
}
log.Error("[JS] Exception: %s", exc)
if Disabled {
return
}
Setup()
width, _ := shmem.CurrentRenderEngine.WindowSize()
Window.MoveTo(render.Point{
X: (width / 2) - (Window.Size().W / 2),
Y: 60,
})
Window.Show()
lastException = exc
*excLabel = trim(exc)
}
// FormatAndCatch an exception from a JavaScript VM. This is the common function called
// immediately when a scripting-related panic is recovered, and appends stack trace frames
// and formats the message for final Catch display.
func FormatAndCatch(vm *goja.Runtime, exc string, args ...interface{}) {
// Collect the JavaScript stack frame for debugging.
var (
buf [1000]goja.StackFrame
jsCallers = []string{}
sections = []string{
fmt.Sprintf(exc, args...),
}
)
if vm != nil {
frames := vm.CaptureCallStack(1000, buf[:0])
for i, frame := range frames {
var position = frame.Position()
jsCallers = append(jsCallers, fmt.Sprintf("%d. %s at %s line %d column %d", i+1, frame.FuncName(), position.Filename, position.Line, position.Column))
}
if len(jsCallers) > 0 {
sections = append(sections, fmt.Sprintf("JS stack:\n%s", strings.Join(jsCallers, "\n")))
}
}
sections = append(sections, fmt.Sprintf("Go stack:\n%s", debugging.StringifyCallers()))
Catch(
strings.Join(sections, "\n\n"),
)
}
// Setup the global supervisor and window the first time - after the render engine has initialized,
// e.g., when you want the window to show up the first time.
func Setup() {
mu.Lock()
defer mu.Unlock()
log.Info("Setup Exceptions")
if Supervisor == nil {
Supervisor = ui.NewSupervisor()
}
if Window == nil {
Window = MakeWindow(Exception{
Supervisor: Supervisor,
Engine: shmem.CurrentRenderEngine,
})
Window.Compute(shmem.CurrentRenderEngine)
Window.Supervise(Supervisor)
}
}
// Teardown the exception UI, if it was loaded. This is done between Scene transitions in the game
// to reduce memory leaks in case the scenes will flush SDL2 caches or w/e.
func Teardown() {
log.Error("Teardown Exceptions")
mu.Lock()
defer mu.Unlock()
if Window != nil {
Window = nil
}
if Supervisor != nil {
Supervisor = nil
}
}
// Handled returns true if the exception window handles the events this tick, e.g.
// so clicking on its window won't let your mouse click also hit things behind it.
func Handled(ev *event.State) bool {
if Window != nil && !Window.Hidden() {
// If they hit the Return/Escape key, dismiss the exception.
if ev.Enter || ev.Escape {
ev.Escape = false
Window.Close()
return true
}
Supervisor.Loop(ev)
// NOTE: if the window Close handler tears down the Supervisor, the
// previous call to Supervisor.Loop() may run it and then Supervisor
// no longer exists.
if Supervisor != nil && Supervisor.IsPointInWindow(shmem.Cursor) {
return true
}
}
return false
}
// Loop allows the exception window to appear on game tick.
func Draw(e render.Engine) {
if Supervisor != nil {
Supervisor.Present(e)
}
}
// Exception window to show scripting errors in doodads.
type Exception struct {
// Settings passed in by doodle
Supervisor *ui.Supervisor
Engine render.Engine
}
// Function to trim the raw exception text so it fits neatly within the label.
// In case it's long, use the Copy button to copy to your clipboard.
func trim(input string) string {
var lines = []string{}
for _, line := range strings.Split(input, "\n") {
if len(lines) >= charsTall {
lines = lines[:charsTall]
break
}
if len(line) > charsWide {
// Word wrap it.
for len(line) > charsWide {
lines = append(lines, line[:charsWide])
line = line[charsWide:]
}
if len(line) > 0 {
lines = append(lines, line)
}
continue
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
// MakeWindow initializes the window.
func MakeWindow(cfg Exception) *ui.Window {
window := ui.NewWindow("Exception")
window.Configure(ui.Config{
Width: winSize.W,
Height: winSize.H,
Background: render.Red.Lighten(128),
})
window.Handle(ui.CloseWindow, func(ed ui.EventData) error {
Teardown()
return ui.ErrStopPropagation
})
header := ui.NewLabel(ui.Label{
Text: "A JavaScript error has occurred in a doodad:",
Font: balance.UIFont,
})
window.Pack(header, ui.Pack{
Side: ui.N,
Padding: 8,
})
text := ui.NewLabel(ui.Label{
TextVariable: excLabel,
Font: balance.ExceptionFont,
})
text.Configure(ui.Config{
BorderSize: 1,
BorderStyle: ui.BorderSunken,
Background: render.White,
Width: excSize.W,
Height: excSize.H,
})
window.Pack(text, ui.Pack{
Side: ui.N,
})
frame := ui.NewFrame("Button frame")
buttons := []struct {
label string
tooltip string
f func()
}{
{"Dismiss", "", func() {
Window.Close()
}},
{"Copy", "Copy the full text to clipboard", func() {
native.CopyToClipboard(lastException)
}},
{"Don't show again", "Don't show errors like this again\nuntil your next play session", func() {
Disabled = true
Window.Close()
}},
}
for i, button := range buttons {
button := button
btn := ui.NewButton(button.label, ui.NewLabel(ui.Label{
Text: button.label,
Font: balance.MenuFont,
}))
if i == 0 {
btn.SetStyle(&balance.ButtonPrimary)
}
btn.Handle(ui.Click, func(ed ui.EventData) error {
button.f()
return nil
})
btn.Compute(cfg.Engine)
// Tooltips?
if len(button.tooltip) > 0 {
ui.NewTooltip(btn, ui.Tooltip{
Text: button.tooltip,
Edge: ui.Bottom,
})
}
cfg.Supervisor.Add(btn)
frame.Pack(btn, ui.Pack{
Side: ui.W,
PadX: 4,
Expand: true,
Fill: true,
})
}
window.Pack(frame, ui.Pack{
Side: ui.N,
PadY: 12,
})
return window
}