From 258b2eb285913158ece792ab865e6122fd2b6d39 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 18 Apr 2019 18:15:05 -0700 Subject: [PATCH] Script Timers, Multiple Doodad Frames * CLI: fix the `doodad convert` command to share the same Palette when converting each frame (layer) of a doodad so subsequent layers find the correct color swatches for serialization. * Scripting: add timers and intervals to Doodad scripts to allow them to animate themselves or add delayed callbacks. The timers have the same API as a web browser: setTimeout(), setInterval(), clearTimeout(), clearInterval(). * Add support for uix.Actor to change its currently rendered layer in the level. For example a Button Doodad can set its image to Layer 1 (pressed) when touched by the player, and Trapdoors can cycle through their layers to animate opening and closing. * Usage from a Doodad script: Self.ShowLayer(1) * Default Doodads: added scripts for all Buttons, Doors, Keys and the Trapdoor to run their various animations when touched (in the case of Keys, destroy themselves when touched, because there is no player inventory yet) --- cmd/doodad/commands/convert.go | 18 ++-- dev-assets/doodads/buttons/button.js | 15 +++- dev-assets/doodads/buttons/sticky.js | 5 +- dev-assets/doodads/doors/electric-door.js | 35 ++++++++ dev-assets/doodads/doors/keys.js | 5 ++ dev-assets/doodads/doors/locked-door.js | 5 ++ dev-assets/doodads/trapdoors/down.js | 55 ++++++++++++ pkg/collision/collide_actors.go | 2 - pkg/editor_scene.go | 8 -- pkg/fps.go | 2 +- pkg/play_scene.go | 16 ++-- pkg/scripting/events.go | 53 +++++++---- pkg/scripting/scripting.go | 18 +++- pkg/scripting/timers.go | 105 ++++++++++++++++++++++ pkg/scripting/vm.go | 26 +++++- pkg/uix/actor.go | 29 ++++++ pkg/uix/canvas.go | 23 ++++- pkg/uix/canvas_actors.go | 2 +- 18 files changed, 358 insertions(+), 64 deletions(-) create mode 100644 dev-assets/doodads/doors/electric-door.js create mode 100644 dev-assets/doodads/doors/keys.js create mode 100644 dev-assets/doodads/doors/locked-door.js create mode 100644 dev-assets/doodads/trapdoors/down.js create mode 100644 pkg/scripting/timers.go diff --git a/cmd/doodad/commands/convert.go b/cmd/doodad/commands/convert.go index dd9bcae..790d778 100644 --- a/cmd/doodad/commands/convert.go +++ b/cmd/doodad/commands/convert.go @@ -148,14 +148,16 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou doodad.Author = os.Getenv("USER") // Write the first layer and gather its palette. - palette, layer0 := imageToChunker(images[0], chroma, chunkSize) + log.Info("Converting first layer to drawing and getting the palette") + palette, layer0 := imageToChunker(images[0], chroma, nil, chunkSize) doodad.Palette = palette doodad.Layers[0].Chunker = layer0 // Write any additional layers. if len(images) > 1 { for i, img := range images[1:] { - _, chunker := imageToChunker(img, chroma, chunkSize) + log.Info("Converting extra layer %d", i+1) + _, chunker := imageToChunker(img, chroma, palette, chunkSize) doodad.Layers = append(doodad.Layers, doodads.Layer{ Name: fmt.Sprintf("layer-%d", i+1), Chunker: chunker, @@ -180,7 +182,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou lvl.Title = "Converted Level" } lvl.Author = os.Getenv("USER") - palette, chunker := imageToChunker(images[0], chroma, lvl.Chunker.Size) + palette, chunker := imageToChunker(images[0], chroma, nil, lvl.Chunker.Size) lvl.Palette = palette lvl.Chunker = chunker @@ -275,15 +277,21 @@ func drawingToImage(c *cli.Context, chroma render.Color, inputFiles []string, ou // // img: input image like a PNG // chroma: transparent color -func imageToChunker(img image.Image, chroma render.Color, chunkSize int) (*level.Palette, *level.Chunker) { +func imageToChunker(img image.Image, chroma render.Color, palette *level.Palette, chunkSize int) (*level.Palette, *level.Chunker) { var ( - palette = level.NewPalette() chunker = level.NewChunker(chunkSize) bounds = img.Bounds() ) + if palette == nil { + palette = level.NewPalette() + } + // Cache a palette of unique colors as we go. var uniqueColor = map[string]*level.Swatch{} + for _, swatch := range palette.Swatches { + uniqueColor[swatch.Color.String()] = swatch + } for x := bounds.Min.X; x < bounds.Max.X; x++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ { diff --git a/dev-assets/doodads/buttons/button.js b/dev-assets/doodads/buttons/button.js index f640ca0..83318fb 100644 --- a/dev-assets/doodads/buttons/button.js +++ b/dev-assets/doodads/buttons/button.js @@ -1,8 +1,17 @@ function main() { - console.log("Sticky Button initialized!"); + console.log("%s initialized!", Self.Doodad.Title); + + var timer = 0; Events.OnCollide( function() { - console.log("Touched!"); - Self.Canvas.SetBackground(RGBA(255, 153, 0, 153)) + if (timer > 0) { + clearTimeout(timer); + } + + Self.ShowLayer(1); + timer = setTimeout(function() { + Self.ShowLayer(0); + timer = 0; + }, 200); }) } diff --git a/dev-assets/doodads/buttons/sticky.js b/dev-assets/doodads/buttons/sticky.js index f640ca0..cf4fe74 100644 --- a/dev-assets/doodads/buttons/sticky.js +++ b/dev-assets/doodads/buttons/sticky.js @@ -1,8 +1,7 @@ function main() { - console.log("Sticky Button initialized!"); + console.log("%s initialized!", Self.Doodad.Title); Events.OnCollide( function() { - console.log("Touched!"); - Self.Canvas.SetBackground(RGBA(255, 153, 0, 153)) + Self.ShowLayer(1); }) } diff --git a/dev-assets/doodads/doors/electric-door.js b/dev-assets/doodads/doors/electric-door.js new file mode 100644 index 0000000..dedae0d --- /dev/null +++ b/dev-assets/doodads/doors/electric-door.js @@ -0,0 +1,35 @@ +function main() { + console.log("%s initialized!", Self.Doodad.Title); + + var timer = 0; + + // Animation frames. + var frame = 0; + var frames = Self.LayerCount(); + var animationDirection = 1; // forward or backward + var animationSpeed = 100; // interval between frames when animating + var animating = false; // true if animation is actively happening + + console.warn("Electric Door has %d frames", frames); + + // Animation interval function. + setInterval(function() { + if (!animating) { + return; + } + + // Advance the frame forwards or backwards. + frame += animationDirection; + if (frame >= frames) { + // Reached the last frame, start the pause and reverse direction. + animating = false; + frame = frames - 1; + } + + Self.ShowLayer(frame); + }, animationSpeed); + + Events.OnCollide( function() { + animating = true; // start the animation + }) +} diff --git a/dev-assets/doodads/doors/keys.js b/dev-assets/doodads/doors/keys.js new file mode 100644 index 0000000..ab81605 --- /dev/null +++ b/dev-assets/doodads/doors/keys.js @@ -0,0 +1,5 @@ +function main() { + Events.OnCollide(function(e) { + Self.Destroy(); + }) +} diff --git a/dev-assets/doodads/doors/locked-door.js b/dev-assets/doodads/doors/locked-door.js new file mode 100644 index 0000000..7d1ee7a --- /dev/null +++ b/dev-assets/doodads/doors/locked-door.js @@ -0,0 +1,5 @@ +function main() { + Events.OnCollide(function(e) { + Self.ShowLayer(1); + }); +} diff --git a/dev-assets/doodads/trapdoors/down.js b/dev-assets/doodads/trapdoors/down.js new file mode 100644 index 0000000..a38b70d --- /dev/null +++ b/dev-assets/doodads/trapdoors/down.js @@ -0,0 +1,55 @@ +function main() { + console.log("%s initialized!", Self.Doodad.Title); + + var timer = 0; + + // Animation frames. + var frame = 0; + var frames = Self.LayerCount(); + var animationDirection = 1; // forward or backward + var animationSpeed = 100; // interval between frames when animating + var animationDelay = 8; // delay ticks at the end before reversing, in + // multiples of animationSpeed + var delayCountdown = 0; + var animating = false; // true if animation is actively happening + + console.warn("Trapdoor has %d frames", frames); + + // Animation interval function. + setInterval(function() { + if (!animating) { + return; + } + + // At the end of the animation (door is open), delay before resuming + // the close animation. + if (delayCountdown > 0) { + delayCountdown--; + return; + } + + // Advance the frame forwards or backwards. + frame += animationDirection; + if (frame >= frames) { + // Reached the last frame, start the pause and reverse direction. + delayCountdown = animationDelay; + animationDirection = -1; + + // also bounds check it + frame = frames - 1; + } + + if (frame < 0) { + // reached the start again + frame = 0; + animationDirection = 1; + animating = false; + } + + Self.ShowLayer(frame); + }, animationSpeed); + + Events.OnCollide( function() { + animating = true; // start the animation + }) +} diff --git a/pkg/collision/collide_actors.go b/pkg/collision/collide_actors.go index a8672f5..899c57a 100644 --- a/pkg/collision/collide_actors.go +++ b/pkg/collision/collide_actors.go @@ -2,7 +2,6 @@ package collision import ( "git.kirsle.net/apps/doodle/lib/render" - "git.kirsle.net/apps/doodle/pkg/log" ) // IndexTuple holds two integers used as array indexes. @@ -21,7 +20,6 @@ func BetweenBoxes(boxes []render.Rect) chan IndexTuple { for i, box := range boxes { for j := i + 1; j < len(boxes); j++ { if box.Intersects(boxes[j]) { - log.Info("Actor %d intersects %d", i, j) generator <- IndexTuple{i, j} } } diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index 455eff1..91f45e5 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -181,14 +181,6 @@ func (s *EditorScene) LoadLevel(filename string) error { s.Level = level s.UI.Canvas.LoadLevel(s.d.Engine, s.Level) - // TODO: debug - for i, actor := range level.Actors { - log.Info("Actor %s is a %s", i, actor.ID()) - } - for name, file := range level.Files { - log.Info("File %s has: %s", name, file.Data) - } - log.Info("Installing %d actors into the drawing", len(level.Actors)) if err := s.UI.Canvas.InstallActors(level.Actors); err != nil { return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err) diff --git a/pkg/fps.go b/pkg/fps.go index 75daa43..358fb18 100644 --- a/pkg/fps.go +++ b/pkg/fps.go @@ -18,7 +18,7 @@ const maxSamples = 100 // like: boolProp DebugOverlay true var ( DebugOverlay = true - DebugCollision = true + DebugCollision = false DebugTextPadding int32 = 8 DebugTextSize = 24 diff --git a/pkg/play_scene.go b/pkg/play_scene.go index 9ae4e19..a43db46 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -130,7 +130,11 @@ func (s *PlayScene) Loop(d *Doodle, ev *events.State) error { return nil } - // s.drawing.Loop(ev) + // Loop the script supervisor so timeouts/intervals can fire in scripts. + if err := s.scripting.Loop(); err != nil { + log.Error("PlayScene.Loop: scripting.Loop: %s", err) + } + s.movePlayer(ev) if err := s.drawing.Loop(ev); err != nil { log.Error("Drawing loop error: %s", err.Error()) @@ -147,16 +151,6 @@ func (s *PlayScene) Draw(d *Doodle) error { // Draw the level. s.drawing.Present(d.Engine, s.drawing.Point()) - // Draw our hero. TODO: this draws a yellow box using the player's World - // Position as tho it were Screen Position. The player has its own canvas - // currently drawn in red - d.Engine.DrawBox(render.RGBA(255, 255, 153, 64), render.Rect{ - X: s.Player.Position().X, - Y: s.Player.Position().Y, - W: s.Player.Size().W, - H: s.Player.Size().H, - }) - // Draw out bounding boxes. d.DrawCollisionBox(s.Player) diff --git a/pkg/scripting/events.go b/pkg/scripting/events.go index 37aef9a..7cf63de 100644 --- a/pkg/scripting/events.go +++ b/pkg/scripting/events.go @@ -4,6 +4,16 @@ import ( "github.com/robertkrimen/otto" ) +// Event name constants. +const ( + CollideEvent = "OnCollide" // another doodad collides with us + EnterEvent = "OnEnter" // a doodad is fully inside us + LeaveEvent = "OnLeave" // a doodad no longer collides with us + + // Controllable (player character) doodad events + KeypressEvent = "OnKeypress" // i.e. arrow keys +) + // Events API for Doodad scripts. type Events struct { registry map[string][]otto.Value @@ -18,27 +28,37 @@ func NewEvents() *Events { // OnCollide fires when another actor collides with yours. func (e *Events) OnCollide(call otto.FunctionCall) otto.Value { - callback := call.Argument(0) - if !callback.IsFunction() { - return otto.Value{} // TODO - } - - if _, ok := e.registry[CollideEvent]; !ok { - e.registry[CollideEvent] = []otto.Value{} - } - - e.registry[CollideEvent] = append(e.registry[CollideEvent], callback) - return otto.Value{} + return e.register(CollideEvent, call.Argument(0)) } // RunCollide invokes the OnCollide handler function. func (e *Events) RunCollide() error { - if _, ok := e.registry[CollideEvent]; !ok { + return e.run(CollideEvent) +} + +// register a named event. +func (e *Events) register(name string, callback otto.Value) otto.Value { + if !callback.IsFunction() { + return otto.Value{} // TODO + } + + if _, ok := e.registry[name]; !ok { + e.registry[name] = []otto.Value{} + } + + e.registry[name] = append(e.registry[name], callback) + return otto.Value{} +} + +// Run an event handler. Returns an error only if there was a JavaScript error +// inside the function. If there are no event handlers, just returns nil. +func (e *Events) run(name string, args ...interface{}) error { + if _, ok := e.registry[name]; !ok { return nil } - for _, callback := range e.registry[CollideEvent] { - _, err := callback.Call(otto.Value{}, "test argument") + for _, callback := range e.registry[name] { + _, err := callback.Call(otto.Value{}, args...) if err != nil { return err } @@ -46,8 +66,3 @@ func (e *Events) RunCollide() error { return nil } - -// Event name constants. -const ( - CollideEvent = "collide" -) diff --git a/pkg/scripting/scripting.go b/pkg/scripting/scripting.go index 0252edb..a2c8b77 100644 --- a/pkg/scripting/scripting.go +++ b/pkg/scripting/scripting.go @@ -5,6 +5,7 @@ package scripting import ( "errors" "fmt" + "time" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" @@ -23,6 +24,15 @@ func NewSupervisor() *Supervisor { } } +// Loop the supervisor to invoke timer events in any running scripts. +func (s *Supervisor) Loop() error { + now := time.Now() + for _, vm := range s.scripts { + vm.TickTimer(now) + } + return nil +} + // InstallScripts loads scripts for all actors in the level. func (s *Supervisor) InstallScripts(level *level.Level) error { for _, actor := range level.Actors { @@ -47,9 +57,11 @@ func (s *Supervisor) To(name string) *VM { return vm } - log.Error("scripting.Supervisor.To(%s): no such VM but returning blank VM", - name, - ) + // TODO: put this log back in, but add PLAYER script so it doesn't spam + // the console for missing PLAYER. + // log.Error("scripting.Supervisor.To(%s): no such VM but returning blank VM", + // name, + // ) return NewVM(name) } diff --git a/pkg/scripting/timers.go b/pkg/scripting/timers.go new file mode 100644 index 0000000..b52c5e5 --- /dev/null +++ b/pkg/scripting/timers.go @@ -0,0 +1,105 @@ +package scripting + +import ( + "time" + + "github.com/robertkrimen/otto" +) + +// Timer keeps track of delayed function calls for the scripting engine. +type Timer struct { + id int + callback otto.Value + interval time.Duration // milliseconds delay for timeout + next time.Time // scheduled time for next invocation + repeat bool // for setInterval +} + +/* +SetTimeout registers a callback function to be run after a while. + +This is to be called by JavaScript running in the VM and has an API similar to +that found in web browsers. + +The callback is a JavaScript function and the interval is in milliseconds, +with 1000 being 'one second.' + +Returns the ID number of the timer in case you want to clear it. The underlying +Timer type is NOT exposed to JavaScript. +*/ +func (vm *VM) SetTimeout(callback otto.Value, interval int) int { + return vm.AddTimer(callback, interval, false) +} + +/* +SetInterval registers a callback function to be run repeatedly. + +Returns the ID number of the timer in case you want to clear it. The underlying +Timer type is NOT exposed to JavaScript. +*/ +func (vm *VM) SetInterval(callback otto.Value, interval int) int { + return vm.AddTimer(callback, interval, true) +} + +/* +AddTimer loads timeouts and intervals into the VM's memory and returns the ID. +*/ +func (vm *VM) AddTimer(callback otto.Value, interval int, repeat bool) int { + // Get the next timer ID. The first timer has ID 1. + vm.timerLastID++ + id := vm.timerLastID + + t := &Timer{ + id: id, + callback: callback, + interval: time.Duration(interval), + repeat: repeat, + } + t.Schedule() + vm.timers[id] = t + + return id +} + +// TickTimer checks if any timers are ready and calls their functions. +func (vm *VM) TickTimer(now time.Time) { + if len(vm.timers) == 0 { + return + } + + // IDs of expired timeouts to clear. + var clear []int + + for id, timer := range vm.timers { + if now.After(timer.next) { + timer.callback.Call(otto.Value{}) + if timer.repeat { + timer.Schedule() + } else { + clear = append(clear, id) + } + } + } + + // Clean up expired timers. + if len(clear) > 0 { + for _, id := range clear { + delete(vm.timers, id) + } + } +} + +/* +ClearTimer will clear both timeouts and intervals. + +In the JavaScript VM this function is bound to clearTimeout() and clearInterval() +to expose an API like that seen in web browsers. +*/ +func (vm *VM) ClearTimer(id int) { + delete(vm.timers, id) +} + +// Schedule the callback to be run in the future. +func (t *Timer) Schedule() { + t.next = time.Now().Add(t.interval * time.Millisecond) +} diff --git a/pkg/scripting/vm.go b/pkg/scripting/vm.go index c5304bf..39bf00e 100644 --- a/pkg/scripting/vm.go +++ b/pkg/scripting/vm.go @@ -1,7 +1,6 @@ package scripting import ( - "errors" "fmt" "git.kirsle.net/apps/doodle/lib/render" @@ -18,6 +17,10 @@ type VM struct { Self interface{} vm *otto.Otto + + // setTimeout and setInterval variables. + timerLastID int // becomes 1 when first timer is set + timers map[int]*Timer } // NewVM creates a new JavaScript VM. @@ -26,6 +29,7 @@ func NewVM(name string) *VM { Name: name, Events: NewEvents(), vm: otto.New(), + timers: map[int]*Timer{}, } return vm } @@ -48,8 +52,14 @@ func (vm *VM) RegisterLevelHooks() error { "log": log.Logger, "RGBA": render.RGBA, "Point": render.NewPoint, - "Self": vm.Self, + "Self": vm.Self, // i.e., the uix.Actor object "Events": vm.Events, + + // Timer functions with APIs similar to the web browsers. + "setTimeout": vm.SetTimeout, + "setInterval": vm.SetInterval, + "clearTimeout": vm.ClearTimer, + "clearInterval": vm.ClearTimer, } for name, v := range bindings { err := vm.vm.Set(name, v) @@ -59,7 +69,15 @@ func (vm *VM) RegisterLevelHooks() error { ) } } - vm.vm.Run(`console = {}; console.log = log.Info;`) + + // Alias the console.log functions to the logger. + vm.vm.Run(` + console = {}; + console.log = log.Info; + console.debug = log.Debug; + console.warn = log.Warn; + console.error = log.Error; + `) return nil } @@ -71,7 +89,7 @@ func (vm *VM) Main() error { } if !function.IsFunction() { - return errors.New("main is not a function") + return nil } _, err = function.Call(otto.Value{}) diff --git a/pkg/uix/actor.go b/pkg/uix/actor.go index 0e79b5c..09c4e34 100644 --- a/pkg/uix/actor.go +++ b/pkg/uix/actor.go @@ -1,6 +1,9 @@ package uix import ( + "errors" + "fmt" + "git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/level" @@ -19,6 +22,9 @@ type Actor struct { doodads.Drawing Actor *level.Actor Canvas *Canvas + + activeLayer int // active drawing frame for display + flagDestroy bool // flag the actor for destruction } // NewActor sets up a uix.Actor. @@ -51,3 +57,26 @@ func NewActor(id string, levelActor *level.Actor, doodad *doodads.Doodad) *Actor return actor } + +// LayerCount returns the number of layers in this actor's drawing. +func (a *Actor) LayerCount() int { + return len(a.Doodad.Layers) +} + +// ShowLayer sets the actor's ActiveLayer to the index given. +func (a *Actor) ShowLayer(index int) error { + if index < 0 { + return errors.New("layer index must be 0 or greater") + } else if index > len(a.Doodad.Layers) { + return fmt.Errorf("layer %d out of range for doodad's layers", index) + } + + a.activeLayer = index + a.Canvas.Load(a.Doodad.Palette, a.Doodad.Layers[index].Chunker) + return nil +} + +// Destroy deletes the actor from the running level. +func (a *Actor) Destroy() { + a.flagDestroy = true +} diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 7b2c369..f86bda7 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -173,6 +173,18 @@ func (w *Canvas) Loop(ev *events.State) error { log.Debug("loopConstrainScroll: %s", err) } + // Remove any actors that were destroyed the previous tick. + var newActors []*Actor + for _, a := range w.actors { + if a.flagDestroy { + continue + } + newActors = append(newActors, a) + } + if len(newActors) < len(w.actors) { + w.actors = newActors + } + // Move any actors. As we iterate over all actors, track their bounding // rectangles so we can later see if any pair of actors intersect each other. boxes := make([]render.Rect, len(w.actors)) @@ -195,7 +207,6 @@ func (w *Canvas) Loop(ev *events.State) error { info, ok := collision.CollidesWithGrid(a, w.chunks, delta) if ok { // Collision happened with world. - log.Error("COLLIDE %+v", info) } delta = info.MoveTo // Move us back where the collision check put us @@ -211,7 +222,7 @@ func (w *Canvas) Loop(ev *events.State) error { // Check collisions between actors. for tuple := range collision.BetweenBoxes(boxes) { - log.Error("Actor %s collides with %s", + log.Debug("Actor %s collides with %s", w.actors[tuple[0]].ID(), w.actors[tuple[1]].ID(), ) @@ -219,8 +230,12 @@ func (w *Canvas) Loop(ev *events.State) error { // Call the OnCollide handler. if w.scripting != nil { - w.scripting.To(a.ID()).Events.RunCollide() - w.scripting.To(b.ID()).Events.RunCollide() + if err := w.scripting.To(a.ID()).Events.RunCollide(); err != nil { + log.Error(err.Error()) + } + if err := w.scripting.To(b.ID()).Events.RunCollide(); err != nil { + log.Error(err.Error()) + } } } diff --git a/pkg/uix/canvas_actors.go b/pkg/uix/canvas_actors.go index 3e2ee90..698386b 100644 --- a/pkg/uix/canvas_actors.go +++ b/pkg/uix/canvas_actors.go @@ -56,7 +56,7 @@ func (w *Canvas) InstallScripts() error { vm.Run(actor.Drawing.Doodad.Script) // Call the main() function. - log.Error("Calling Main() for %s", actor.ID()) + log.Debug("Calling Main() for %s", actor.ID()) if err := vm.Main(); err != nil { log.Error("main() for actor %s errored: %s", actor.ID(), err) }