Checkpoint Flag can Re-assign Player Character

Link a Doodad to a Checkpoint Flag (like you would a Start Flag) and
crossing the flag will replace the player with that doodad. Multiple
checkpoint flags like this can toggle you between characters.

* Azulians are now friendly to player characters who have the word
  "Azulian" in their title.
* Improve Bird as the playable character:
  * Dive animation if the player flies diagonally downwards
  * Animation loop while hovering in the air instead of pausing
* Checkpoint flags don't spam each other on PubSub so much which could
  sometimes lead to deadlocks!

SetPlayerCharacter added to the JavaScript API. The Checkpoint Flag
(not the region) can link to a doodad and replace the player character
with that linked doodad when you activate the checkpoint:

    Actors.SetPlayerCharacter(filename string): like "boy.doodad"

Add various panic catchers to make JavaScript safer and log issues
to console.
This commit is contained in:
Noah 2022-01-18 21:24:36 -08:00
parent 44aba8f1b4
commit 626fd53a84
12 changed files with 174 additions and 39 deletions

View File

@ -52,7 +52,7 @@ function main() {
Events.OnCollide((e) => { Events.OnCollide((e) => {
// If we're diving and we hit the player, game over! // If we're diving and we hit the player, game over!
// Azulians are friendly to Thieves though! // Azulians are friendly to Thieves though!
if (e.Settled && e.Actor.IsPlayer() && e.Actor.Doodad().Filename !== "thief.doodad") { if (e.Settled && e.Actor.IsPlayer() && e.Actor.Doodad().Filename !== "thief.doodad" && e.Actor.Doodad().Title.indexOf("Azulian") < 0) {
FailLevel("Watch out for the Azulians!"); FailLevel("Watch out for the Azulians!");
return; return;
} }

View File

@ -144,32 +144,45 @@ function AI_ScanForPlayer() {
// If under control of the player character. // If under control of the player character.
function player() { function player() {
var playerSpeed = 12;
Self.SetInventory(true); Self.SetInventory(true);
Events.OnKeypress((ev) => { Events.OnKeypress((ev) => {
Vx = 0; Vx = 0;
Vy = 0; Vy = 0;
if (ev.Up) { if (ev.Right) {
Vy = -playerSpeed; direction = "right";
} else if (ev.Down) { } else if (ev.Left) {
Vy = playerSpeed; direction = "left";
} }
if (ev.Right) { // Dive!
if (ev.Down && ev.Right) {
Self.StopAnimation();
Self.ShowLayerNamed("dive-right");
} else if (ev.Down && ev.Left) {
Self.StopAnimation();
Self.ShowLayerNamed("dive-left");
} else if (ev.Right) {
// Fly right.
if (!Self.IsAnimating()) { if (!Self.IsAnimating()) {
Self.PlayAnimation("fly-right", null); Self.PlayAnimation("fly-right", null);
} }
Vx = playerSpeed; Vx = playerSpeed;
} else if (ev.Left) { } else if (ev.Left) {
// Fly left.
if (!Self.IsAnimating()) { if (!Self.IsAnimating()) {
Self.PlayAnimation("fly-left", null); Self.PlayAnimation("fly-left", null);
} }
Vx = -playerSpeed; Vx = -playerSpeed;
} else { } else {
Self.StopAnimation(); // Hover in place.
animating = false; if (!Self.IsAnimating()) {
Self.PlayAnimation("fly-"+direction);
}
} }
Self.SetVelocity(Vector(Vx, Vy)); // Self.SetVelocity(Vector(Vx, Vy));
}) })
} }

View File

@ -1,14 +1,25 @@
// Checkpoint Flag. // Checkpoint Flag.
var isCurrentCheckpoint = false; var isCurrentCheckpoint = false,
playerEntered = false
broadcastCooldown = time.Now();
function main() { function main() {
Self.SetHitbox(22 + 16, 16, 75 - 16, 86); Self.SetHitbox(22 + 16, 16, 75 - 16, 86);
setActive(false); setActive(false);
// If the checkpoint is linked to any doodad, the player character will
// become that doodad when they cross this checkpoint.
let skin = null;
for (let actor of Self.GetLinks()) {
skin = actor.Filename;
actor.Destroy();
}
// Checkpoints broadcast to all of their peers so they all // Checkpoints broadcast to all of their peers so they all
// know which one is the most recently activated. // know which one is the most recently activated.
Message.Subscribe("broadcast:checkpoint", (currentID) => { Message.Subscribe("broadcast:checkpoint", (currentID) => {
setActive(false); setActive(false);
return "a ok";
}); });
Events.OnCollide((e) => { Events.OnCollide((e) => {
@ -21,10 +32,19 @@ function main() {
return; return;
} }
// Set the player checkpoint.
SetCheckpoint(Self.Position()); SetCheckpoint(Self.Position());
setActive(true); setActive(true);
Message.Broadcast("broadcast:checkpoint", Self.ID())
// Don't spam the PubSub queue or we get races and deadlocks.
if (time.Now().After(broadcastCooldown)) {
Message.Broadcast("broadcast:checkpoint", Self.ID());
broadcastCooldown = time.Now().Add(5 * time.Second)
}
// Are we setting a new player skin?
if (skin && e.Actor.Doodad().Filename !== skin) {
Actors.SetPlayerCharacter(skin);
}
}); });
} }

View File

@ -107,24 +107,17 @@ function ai() {
// If under control of the player character. // If under control of the player character.
function playable() { function playable() {
Events.OnKeypress((ev) => { Events.OnKeypress((ev) => {
Vx = 0;
Vy = 0;
if (ev.Right) { if (ev.Right) {
if (!Self.IsAnimating()) { if (!Self.IsAnimating()) {
Self.PlayAnimation("walk-right", null); Self.PlayAnimation("walk-right", null);
} }
Vx = playerSpeed;
} else if (ev.Left) { } else if (ev.Left) {
if (!Self.IsAnimating()) { if (!Self.IsAnimating()) {
Self.PlayAnimation("walk-left", null); Self.PlayAnimation("walk-left", null);
} }
Vx = -playerSpeed;
} else { } else {
Self.StopAnimation(); Self.StopAnimation();
animating = false; animating = false;
} }
// Self.SetVelocity(Point(Vx, Vy));
}) })
} }

View File

@ -221,6 +221,9 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
} }
} }
// Handle a doodad changing the player character.
s.drawing.OnSetPlayerCharacter = s.SetPlayerCharacter
// Given a filename or map data to play? // Given a filename or map data to play?
if s.Level != nil { if s.Level != nil {
log.Debug("PlayScene.Setup: received level from scene caller") log.Debug("PlayScene.Setup: received level from scene caller")
@ -259,7 +262,7 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
} }
// Load in the player character. // Load in the player character.
s.setupPlayer() s.setupPlayer(balance.PlayerCharacterDoodad)
// Run all the actor scripts' main() functions. // Run all the actor scripts' main() functions.
if err := s.drawing.InstallScripts(); err != nil { if err := s.drawing.InstallScripts(); err != nil {
@ -286,15 +289,29 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
return nil return nil
} }
// SetPlayerCharacter changes the doodad used for the player, by destroying the
// current player character and making it from scratch.
func (s *PlayScene) SetPlayerCharacter(filename string) {
spawn := s.Player.Position()
s.Player.Destroy()
s.drawing.RemoveActor(s.Player)
log.Info("SetPlayerCharacter: %s", filename)
s.installPlayerDoodad(filename, spawn, render.Rect{})
if err := s.drawing.InstallScripts(); err != nil {
log.Error("SetPlayerCharacter: InstallScripts: %s", err)
}
}
// setupPlayer creates and configures the Player Character in the level. // setupPlayer creates and configures the Player Character in the level.
func (s *PlayScene) setupPlayer() { func (s *PlayScene) setupPlayer(playerCharacterFilename string) {
// Find the spawn point of the player. Search the level for the // Find the spawn point of the player. Search the level for the
// "start-flag.doodad" // "start-flag.doodad"
var ( var (
playerCharacterFilename = balance.PlayerCharacterDoodad isStartFlagCharacter bool
isStartFlagCharacter bool
spawn render.Point spawn render.Point
centerIn render.Rect
flag = &level.Actor{} flag = &level.Actor{}
flagSize = render.NewRect(86, 86) // TODO: start-flag.doodad is 86x86 px flagSize = render.NewRect(86, 86) // TODO: start-flag.doodad is 86x86 px
flagCount int flagCount int
@ -333,23 +350,14 @@ func (s *PlayScene) setupPlayer() {
// The Start Flag becomes the player's initial checkpoint. // The Start Flag becomes the player's initial checkpoint.
s.lastCheckpoint = flag.Point s.lastCheckpoint = flag.Point
// Load in the player character.
player, err := doodads.LoadFile(playerCharacterFilename)
if err != nil {
log.Error("PlayScene.Setup: failed to load player doodad: %s", err)
player = doodads.NewDummy(32)
}
if !s.SpawnPoint.IsZero() { if !s.SpawnPoint.IsZero() {
spawn = s.SpawnPoint spawn = s.SpawnPoint
} else { } else {
spawn = render.NewPoint( spawn = flag.Point
// X: centered inside the flag. centerIn = render.Rect{
flag.Point.X+(flagSize.W/2)-(player.Layers[0].Chunker.Size/2), W: flagSize.W,
H: flagSize.H,
// Y: the bottom of the flag, 4 pixels from the floor. }
flag.Point.Y+flagSize.H-4-(player.Layers[0].Chunker.Size),
)
} }
// Surface warnings around the spawn flag. // Surface warnings around the spawn flag.
@ -359,6 +367,33 @@ func (s *PlayScene) setupPlayer() {
s.d.FlashError("Warning: this level contains multiple Start Flags. Player spawn point is ambiguous.") s.d.FlashError("Warning: this level contains multiple Start Flags. Player spawn point is ambiguous.")
} }
s.installPlayerDoodad(playerCharacterFilename, spawn, centerIn)
}
// Load and install the player doodad onto the level.
// Make sure the previous PLAYER was removed.
// If spawn is zero, uses the player's last spawn point.
// centerIn is optional, ignored if zero.
func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, centerIn render.Rect) {
// Load in the player character.
player, err := doodads.LoadFile(filename)
if err != nil {
log.Error("PlayScene.Setup: failed to load player doodad: %s", err)
player = doodads.NewDummy(32)
}
// Center the player within the box of the doodad, for the Start Flag especially.
if !centerIn.IsZero() {
spawn = render.NewPoint(
spawn.X+(centerIn.W/2)-(player.Layers[0].Chunker.Size/2),
// Y: the bottom of the flag, 4 pixels from the floor.
spawn.Y+centerIn.H-4-(player.Layers[0].Chunker.Size),
)
} else if spawn.IsZero() && !s.SpawnPoint.IsZero() {
spawn = s.SpawnPoint
}
s.Player = uix.NewActor("PLAYER", &level.Actor{}, player) s.Player = uix.NewActor("PLAYER", &level.Actor{}, player)
s.Player.MoveTo(spawn) s.Player.MoveTo(spawn)
s.drawing.AddActor(s.Player) s.drawing.AddActor(s.Player)

View File

@ -6,6 +6,7 @@ import (
"sync" "sync"
"git.kirsle.net/apps/doodle/pkg/keybind" "git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/log"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@ -99,6 +100,15 @@ func (e *Events) run(name string, args ...interface{}) error {
e.lock.RLock() e.lock.RLock()
defer e.lock.RUnlock() defer e.lock.RUnlock()
defer func() {
if err := recover(); err != nil {
// TODO EXCEPTIONS: I once saw a "runtime error: index out of range [-1]"
// from an OnCollide handler between azu-white and thief that was crashing
// the app, report this upstream nicely to the user.
log.Error("PANIC: JS %s handler: %s", name, err)
}
}()
if _, ok := e.registry[name]; !ok { if _, ok := e.registry[name]; !ok {
return nil return nil
} }
@ -116,6 +126,10 @@ func (e *Events) run(name string, args ...interface{}) error {
value, err := function(goja.Undefined(), params...) value, err := function(goja.Undefined(), params...)
if err != nil { if err != nil {
// TODO EXCEPTIONS: this err is useful like
// `ReferenceError: playerSpeed is not defined at <eval>:173:9(93)`
// but wherever we're returning the err to isn't handling it!
log.Error("Scripting error on %s: %s", name, err)
return err return err
} }

View File

@ -23,6 +23,14 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
// Goroutine to watch the VM's inbound channel and invoke Subscribe handlers // Goroutine to watch the VM's inbound channel and invoke Subscribe handlers
// for any matching messages received. // for any matching messages received.
go func() { go func() {
// Catch any exceptions raised by the JavaScript VM.
defer func() {
if err := recover(); err != nil {
// TODO EXCEPTIONS
log.Error("RegisterPublishHooks(%s): %s", vm.Name, err)
}
}()
for msg := range vm.Inbound { for msg := range vm.Inbound {
vm.muSubscribe.Lock() vm.muSubscribe.Lock()
@ -30,8 +38,7 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
for _, callback := range vm.subscribe[msg.Name] { for _, callback := range vm.subscribe[msg.Name] {
log.Debug("PubSub: %s receives from %s: %s", vm.Name, msg.SenderID, msg.Name) log.Debug("PubSub: %s receives from %s: %s", vm.Name, msg.SenderID, msg.Name)
if function, ok := goja.AssertFunction(callback); ok { if function, ok := goja.AssertFunction(callback); ok {
result, err := function(goja.Undefined(), msg.Args...) function(goja.Undefined(), msg.Args...)
log.Debug("Result: %s, %s", result, err)
} }
} }
} }
@ -70,6 +77,10 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
"Broadcast": func(name string, v ...goja.Value) { "Broadcast": func(name string, v ...goja.Value) {
// Send the message to all actor VMs. // Send the message to all actor VMs.
for _, toVM := range s.scripts { for _, toVM := range s.scripts {
if toVM == nil {
continue
}
if vm.Name == toVM.Name { if vm.Name == toVM.Name {
log.Debug("Broadcast(%s): skip to vm '%s' cuz it is the sender", name, toVM.Name) log.Debug("Broadcast(%s): skip to vm '%s' cuz it is the sender", name, toVM.Name)
continue continue

View File

@ -109,3 +109,12 @@ func (s *Supervisor) GetVM(name string) (*VM, error) {
} }
return nil, errors.New("not found") return nil, errors.New("not found")
} }
// RemoveVM removes a script from the supervisor, stopping it.
func (s *Supervisor) RemoveVM(name string) error {
if _, ok := s.scripts[name]; ok {
delete(s.scripts, name)
return nil
}
return errors.New("not found")
}

View File

@ -39,6 +39,10 @@ func (w *Canvas) loopActorCollision() error {
// trying to take your inventory. // trying to take your inventory.
// var wg sync.WaitGroup // var wg sync.WaitGroup
for i, a := range w.actors { for i, a := range w.actors {
if a.IsFrozen() {
continue
}
// wg.Add(1) // wg.Add(1)
//go //go
func(i int, a *Actor) { func(i int, a *Actor) {
@ -236,6 +240,9 @@ func (w *Canvas) loopActorCollision() error {
lastGoodBox.X = lockX lastGoodBox.X = lockX
} }
} else { } else {
if err != nil {
log.Error("RunCollide on %s (%s) errored: %s", a.ID(), a.Actor.Filename, err)
}
// Move them back to the last good box. // Move them back to the last good box.
lastGoodBox = test lastGoodBox = test
} }

View File

@ -91,6 +91,10 @@ type Canvas struct {
// Collision handlers for level geometry. // Collision handlers for level geometry.
OnLevelCollision func(*Actor, *collision.Collide) OnLevelCollision func(*Actor, *collision.Collide)
// Handler when a doodad script called Actors.SetPlayerCharacter.
// The filename.doodad is given.
OnSetPlayerCharacter func(filename string)
/******** /********
* Editable canvas private variables. * Editable canvas private variables.
********/ ********/

View File

@ -81,6 +81,11 @@ func (w *Canvas) InstallScripts() error {
for _, actor := range w.actors { for _, actor := range w.actors {
vm := w.scripting.To(actor.ID()) vm := w.scripting.To(actor.ID())
if vm.Self != nil {
// Already initialized!
continue
}
// Security: expose a selective API to the actor to the JS engine. // Security: expose a selective API to the actor to the JS engine.
vm.Self = w.MakeSelfAPI(actor) vm.Self = w.MakeSelfAPI(actor)
w.MakeScriptAPI(vm) w.MakeScriptAPI(vm)
@ -114,6 +119,19 @@ func (w *Canvas) AddActor(actor *Actor) error {
return nil return nil
} }
// RemoveActor removes the actor from the canvas.
func (w *Canvas) RemoveActor(actor *Actor) {
var actors = []*Actor{}
for _, exist := range w.actors {
if actor == exist {
w.scripting.RemoveVM(actor.ID())
continue
}
actors = append(actors, exist)
}
w.actors = actors
}
// drawActors is a subroutine of Present() that superimposes the actors on top // drawActors is a subroutine of Present() that superimposes the actors on top
// of the level drawing. // of the level drawing.
func (w *Canvas) drawActors(e render.Engine, p render.Point) { func (w *Canvas) drawActors(e render.Engine, p render.Point) {

View File

@ -54,6 +54,15 @@ func (w *Canvas) MakeScriptAPI(vm *scripting.VM) {
return actor return actor
}, },
// Actors.SetPlayerCharacter: remake the player character.
"SetPlayerCharacter": func(filename string) {
if w.OnSetPlayerCharacter != nil {
w.OnSetPlayerCharacter(filename)
} else {
log.Error("Actors.SetPlayerCharacter: caller was not ready")
}
},
}) })
} }
@ -98,6 +107,8 @@ func (w *Canvas) MakeSelfAPI(actor *Actor) map[string]interface{} {
"HasItem": actor.HasItem, "HasItem": actor.HasItem,
"ClearInventory": actor.ClearInventory, "ClearInventory": actor.ClearInventory,
"Destroy": actor.Destroy, "Destroy": actor.Destroy,
"Freeze": actor.Freeze,
"Unfreeze": actor.Unfreeze,
"Hide": actor.Hide, "Hide": actor.Hide,
"Show": actor.Show, "Show": actor.Show,
"GetLinks": func() []map[string]interface{} { "GetLinks": func() []map[string]interface{} {