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)
This commit is contained in:
Noah 2019-04-18 18:15:05 -07:00
parent 81cb3bd617
commit 258b2eb285
18 changed files with 358 additions and 64 deletions

View File

@ -148,14 +148,16 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
doodad.Author = os.Getenv("USER") doodad.Author = os.Getenv("USER")
// Write the first layer and gather its palette. // 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.Palette = palette
doodad.Layers[0].Chunker = layer0 doodad.Layers[0].Chunker = layer0
// Write any additional layers. // Write any additional layers.
if len(images) > 1 { if len(images) > 1 {
for i, img := range 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{ doodad.Layers = append(doodad.Layers, doodads.Layer{
Name: fmt.Sprintf("layer-%d", i+1), Name: fmt.Sprintf("layer-%d", i+1),
Chunker: chunker, Chunker: chunker,
@ -180,7 +182,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
lvl.Title = "Converted Level" lvl.Title = "Converted Level"
} }
lvl.Author = os.Getenv("USER") 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.Palette = palette
lvl.Chunker = chunker lvl.Chunker = chunker
@ -275,15 +277,21 @@ func drawingToImage(c *cli.Context, chroma render.Color, inputFiles []string, ou
// //
// img: input image like a PNG // img: input image like a PNG
// chroma: transparent color // 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 ( var (
palette = level.NewPalette()
chunker = level.NewChunker(chunkSize) chunker = level.NewChunker(chunkSize)
bounds = img.Bounds() bounds = img.Bounds()
) )
if palette == nil {
palette = level.NewPalette()
}
// Cache a palette of unique colors as we go. // Cache a palette of unique colors as we go.
var uniqueColor = map[string]*level.Swatch{} 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 x := bounds.Min.X; x < bounds.Max.X; x++ {
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {

View File

@ -1,8 +1,17 @@
function main() { function main() {
console.log("Sticky Button initialized!"); console.log("%s initialized!", Self.Doodad.Title);
var timer = 0;
Events.OnCollide( function() { Events.OnCollide( function() {
console.log("Touched!"); if (timer > 0) {
Self.Canvas.SetBackground(RGBA(255, 153, 0, 153)) clearTimeout(timer);
}
Self.ShowLayer(1);
timer = setTimeout(function() {
Self.ShowLayer(0);
timer = 0;
}, 200);
}) })
} }

View File

@ -1,8 +1,7 @@
function main() { function main() {
console.log("Sticky Button initialized!"); console.log("%s initialized!", Self.Doodad.Title);
Events.OnCollide( function() { Events.OnCollide( function() {
console.log("Touched!"); Self.ShowLayer(1);
Self.Canvas.SetBackground(RGBA(255, 153, 0, 153))
}) })
} }

View File

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

View File

@ -0,0 +1,5 @@
function main() {
Events.OnCollide(function(e) {
Self.Destroy();
})
}

View File

@ -0,0 +1,5 @@
function main() {
Events.OnCollide(function(e) {
Self.ShowLayer(1);
});
}

View File

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

View File

@ -2,7 +2,6 @@ package collision
import ( import (
"git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/pkg/log"
) )
// IndexTuple holds two integers used as array indexes. // 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 i, box := range boxes {
for j := i + 1; j < len(boxes); j++ { for j := i + 1; j < len(boxes); j++ {
if box.Intersects(boxes[j]) { if box.Intersects(boxes[j]) {
log.Info("Actor %d intersects %d", i, j)
generator <- IndexTuple{i, j} generator <- IndexTuple{i, j}
} }
} }

View File

@ -181,14 +181,6 @@ func (s *EditorScene) LoadLevel(filename string) error {
s.Level = level s.Level = level
s.UI.Canvas.LoadLevel(s.d.Engine, s.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)) log.Info("Installing %d actors into the drawing", len(level.Actors))
if err := s.UI.Canvas.InstallActors(level.Actors); err != nil { if err := s.UI.Canvas.InstallActors(level.Actors); err != nil {
return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err) return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err)

View File

@ -18,7 +18,7 @@ const maxSamples = 100
// like: boolProp DebugOverlay true // like: boolProp DebugOverlay true
var ( var (
DebugOverlay = true DebugOverlay = true
DebugCollision = true DebugCollision = false
DebugTextPadding int32 = 8 DebugTextPadding int32 = 8
DebugTextSize = 24 DebugTextSize = 24

View File

@ -130,7 +130,11 @@ func (s *PlayScene) Loop(d *Doodle, ev *events.State) error {
return nil 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) s.movePlayer(ev)
if err := s.drawing.Loop(ev); err != nil { if err := s.drawing.Loop(ev); err != nil {
log.Error("Drawing loop error: %s", err.Error()) log.Error("Drawing loop error: %s", err.Error())
@ -147,16 +151,6 @@ func (s *PlayScene) Draw(d *Doodle) error {
// Draw the level. // Draw the level.
s.drawing.Present(d.Engine, s.drawing.Point()) 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. // Draw out bounding boxes.
d.DrawCollisionBox(s.Player) d.DrawCollisionBox(s.Player)

View File

@ -4,6 +4,16 @@ import (
"github.com/robertkrimen/otto" "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. // Events API for Doodad scripts.
type Events struct { type Events struct {
registry map[string][]otto.Value registry map[string][]otto.Value
@ -18,27 +28,37 @@ func NewEvents() *Events {
// OnCollide fires when another actor collides with yours. // OnCollide fires when another actor collides with yours.
func (e *Events) OnCollide(call otto.FunctionCall) otto.Value { func (e *Events) OnCollide(call otto.FunctionCall) otto.Value {
callback := call.Argument(0) return e.register(CollideEvent, 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{}
} }
// RunCollide invokes the OnCollide handler function. // RunCollide invokes the OnCollide handler function.
func (e *Events) RunCollide() error { 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 return nil
} }
for _, callback := range e.registry[CollideEvent] { for _, callback := range e.registry[name] {
_, err := callback.Call(otto.Value{}, "test argument") _, err := callback.Call(otto.Value{}, args...)
if err != nil { if err != nil {
return err return err
} }
@ -46,8 +66,3 @@ func (e *Events) RunCollide() error {
return nil return nil
} }
// Event name constants.
const (
CollideEvent = "collide"
)

View File

@ -5,6 +5,7 @@ package scripting
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log" "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. // InstallScripts loads scripts for all actors in the level.
func (s *Supervisor) InstallScripts(level *level.Level) error { func (s *Supervisor) InstallScripts(level *level.Level) error {
for _, actor := range level.Actors { for _, actor := range level.Actors {
@ -47,9 +57,11 @@ func (s *Supervisor) To(name string) *VM {
return vm return vm
} }
log.Error("scripting.Supervisor.To(%s): no such VM but returning blank VM", // TODO: put this log back in, but add PLAYER script so it doesn't spam
name, // the console for missing PLAYER.
) // log.Error("scripting.Supervisor.To(%s): no such VM but returning blank VM",
// name,
// )
return NewVM(name) return NewVM(name)
} }

105
pkg/scripting/timers.go Normal file
View File

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

View File

@ -1,7 +1,6 @@
package scripting package scripting
import ( import (
"errors"
"fmt" "fmt"
"git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/render"
@ -18,6 +17,10 @@ type VM struct {
Self interface{} Self interface{}
vm *otto.Otto 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. // NewVM creates a new JavaScript VM.
@ -26,6 +29,7 @@ func NewVM(name string) *VM {
Name: name, Name: name,
Events: NewEvents(), Events: NewEvents(),
vm: otto.New(), vm: otto.New(),
timers: map[int]*Timer{},
} }
return vm return vm
} }
@ -48,8 +52,14 @@ func (vm *VM) RegisterLevelHooks() error {
"log": log.Logger, "log": log.Logger,
"RGBA": render.RGBA, "RGBA": render.RGBA,
"Point": render.NewPoint, "Point": render.NewPoint,
"Self": vm.Self, "Self": vm.Self, // i.e., the uix.Actor object
"Events": vm.Events, "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 { for name, v := range bindings {
err := vm.vm.Set(name, v) 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 return nil
} }
@ -71,7 +89,7 @@ func (vm *VM) Main() error {
} }
if !function.IsFunction() { if !function.IsFunction() {
return errors.New("main is not a function") return nil
} }
_, err = function.Call(otto.Value{}) _, err = function.Call(otto.Value{})

View File

@ -1,6 +1,9 @@
package uix package uix
import ( import (
"errors"
"fmt"
"git.kirsle.net/apps/doodle/lib/render" "git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
@ -19,6 +22,9 @@ type Actor struct {
doodads.Drawing doodads.Drawing
Actor *level.Actor Actor *level.Actor
Canvas *Canvas Canvas *Canvas
activeLayer int // active drawing frame for display
flagDestroy bool // flag the actor for destruction
} }
// NewActor sets up a uix.Actor. // NewActor sets up a uix.Actor.
@ -51,3 +57,26 @@ func NewActor(id string, levelActor *level.Actor, doodad *doodads.Doodad) *Actor
return 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
}

View File

@ -173,6 +173,18 @@ func (w *Canvas) Loop(ev *events.State) error {
log.Debug("loopConstrainScroll: %s", err) 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 // 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. // rectangles so we can later see if any pair of actors intersect each other.
boxes := make([]render.Rect, len(w.actors)) 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) info, ok := collision.CollidesWithGrid(a, w.chunks, delta)
if ok { if ok {
// Collision happened with world. // Collision happened with world.
log.Error("COLLIDE %+v", info)
} }
delta = info.MoveTo // Move us back where the collision check put us 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. // Check collisions between actors.
for tuple := range collision.BetweenBoxes(boxes) { 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[0]].ID(),
w.actors[tuple[1]].ID(), w.actors[tuple[1]].ID(),
) )
@ -219,8 +230,12 @@ func (w *Canvas) Loop(ev *events.State) error {
// Call the OnCollide handler. // Call the OnCollide handler.
if w.scripting != nil { if w.scripting != nil {
w.scripting.To(a.ID()).Events.RunCollide() if err := w.scripting.To(a.ID()).Events.RunCollide(); err != nil {
w.scripting.To(b.ID()).Events.RunCollide() log.Error(err.Error())
}
if err := w.scripting.To(b.ID()).Events.RunCollide(); err != nil {
log.Error(err.Error())
}
} }
} }

View File

@ -56,7 +56,7 @@ func (w *Canvas) InstallScripts() error {
vm.Run(actor.Drawing.Doodad.Script) vm.Run(actor.Drawing.Doodad.Script)
// Call the main() function. // 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 { if err := vm.Main(); err != nil {
log.Error("main() for actor %s errored: %s", actor.ID(), err) log.Error("main() for actor %s errored: %s", actor.ID(), err)
} }