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