Code cleanup

Clean up and improve the between-actors collision code:

* Give names to the A, B tuples from BetweenBoxes and call them the stable and
  mover doodads. All ops are from the perspective of the stable (A) box in
  relation to the mover (B).
* Replace the map[*Actor]*Actor collision struct to one that supports arrays of
  colliding actors, enabling one actor to post OnLeave events correctly when it
  overlapped several actors at once.
* Possibly improve on some of the collision bugs along the way.

Some issues remaining:

* If playing AS an offset-hitbox character:
    * Solid hitboxes against closed doors and platforms works OK.
    * Button InHitbox (overlap) checks do not fire (buttons won't press).
* If playing with a non-offset character (e.g. boy.doodad)
    * Solid hitboxes work and Buttons will press correctly.
* Both types of player hitboxes:
    * When touching a static object such as the Start Flag which has an offset
      hitbox of its own: the Start Flag will creep up the level on its Y axis
      when touched before it settles and won't move.
This commit is contained in:
Noah 2024-07-07 22:45:21 -07:00
parent 618d4b07c5
commit 373028a166
3 changed files with 141 additions and 88 deletions

View File

@ -57,6 +57,9 @@ var (
CoyoteFrames uint64 = 4 // Coyote time, frames after we walk off a cliff but can still jump late CoyoteFrames uint64 = 4 // Coyote time, frames after we walk off a cliff but can still jump late
SlopeMaxHeight = 8 // max pixel height for player to walk up a slope SlopeMaxHeight = 8 // max pixel height for player to walk up a slope
// Collision detection threshold to consider an actor "on top" of a doodad's solid hitbox.
OnTopThreshold = 4
// Number of game ticks to insist the canvas follows the player at the start // Number of game ticks to insist the canvas follows the player at the start
// of a level - to overcome Anvils settling into their starting positions so // of a level - to overcome Anvils settling into their starting positions so
// they don't steal the camera focus straight away. // they don't steal the camera focus straight away.
@ -95,7 +98,7 @@ var (
AutoSaveInterval = 5 * time.Minute AutoSaveInterval = 5 * time.Minute
// Default player character doodad in Play Mode. // Default player character doodad in Play Mode.
PlayerCharacterDoodad = "example-mario.doodad" PlayerCharacterDoodad = "boy.doodad"
// Levelpack and level names for the title screen. // Levelpack and level names for the title screen.
DemoLevelPack = "assets/levelpacks/builtin-Tutorial.levelpack" DemoLevelPack = "assets/levelpacks/builtin-Tutorial.levelpack"

View File

@ -52,6 +52,7 @@ func (w *Canvas) loopActorCollision() error {
originalHitboxes[a.ID()] = collision.GetBoundingRectHitbox(a, a.Hitbox()) originalHitboxes[a.ID()] = collision.GetBoundingRectHitbox(a, a.Hitbox())
// Advance any animations for this actor. // Advance any animations for this actor.
// TODO: wallclock time here, should be set by FPS for consistency.
if a.activeAnimation != nil && a.activeAnimation.nextFrameAt.Before(now) { if a.activeAnimation != nil && a.activeAnimation.nextFrameAt.Before(now) {
if done := a.TickAnimation(a.activeAnimation); done { if done := a.TickAnimation(a.activeAnimation); done {
// Animation has finished, get the callback function. // Animation has finished, get the callback function.
@ -139,29 +140,35 @@ func (w *Canvas) loopActorCollision() error {
// log.Warn("== BEGIN BetweenBoxes") // log.Warn("== BEGIN BetweenBoxes")
var collidingActors = map[*Actor]*Actor{} // Check pairs of all our Actor boxes for overlap and running their OnCollide
// scripts for mobile actors.
var collidingActors = ActorCollisionMap{}
for tuple := range collision.BetweenBoxes(boxes) { for tuple := range collision.BetweenBoxes(boxes) {
a, b := w.actors[tuple.A], w.actors[tuple.B]
// Give the A, B tuple of boxes names: their order doesn't matter.
// Example: stable could be the Button and mover is the Player walking onto it.
// Or: stable could be the Player and mover is a Key that they walked onto.
stable, mover := w.actors[tuple.A], w.actors[tuple.B]
// If neither actor is mobile, don't run collision handlers. // If neither actor is mobile, don't run collision handlers.
if !(a.IsMobile() || b.IsMobile()) { if !(stable.IsMobile() || mover.IsMobile()) {
continue continue
} }
collidingActors[a] = b collidingActors.Set(stable, mover)
log.Error("between boxes: %+v A=<%s> B=<%s>", tuple, a.ID(), b.ID()) log.Error("between boxes: %+v A=<%s> B=<%s>", tuple, stable.ID(), mover.ID())
// Call the OnCollide handler for A informing them of B's intersection. // Call the OnCollide handler for A informing them of B's intersection.
if w.scripting != nil { if w.scripting != nil {
var ( var (
rect = collision.GetBoundingRectHitbox(b, b.Hitbox()) rect = collision.GetBoundingRectHitbox(mover, mover.Hitbox())
// lastGoodBox = rect // lastGoodBox = rect
lastGoodBox = render.Rect{ lastGoodBox = render.Rect{
// Level Positions of the doodad is based on the top left // Level Positions of the doodad is based on the top left
// of its graphical sprite, not its (possibly offset) hitbox. // of its graphical sprite, not its (possibly offset) hitbox.
X: originalPositions[b.ID()].X, X: originalPositions[mover.ID()].X,
Y: originalPositions[b.ID()].Y, Y: originalPositions[mover.ID()].Y,
W: boxes[tuple.B].W, W: boxes[tuple.B].W,
H: boxes[tuple.B].H, H: boxes[tuple.B].H,
} }
@ -178,7 +185,7 @@ func (w *Canvas) loopActorCollision() error {
// use it for collision-check purposes but DON'T physically move // use it for collision-check purposes but DON'T physically move
// the character by it (moving the character may clip them thru // the character by it (moving the character may clip them thru
// other solid hitboxes like the upside-down trapdoor) // other solid hitboxes like the upside-down trapdoor)
var onTopY int // var onTopY int
// Firstly we want to make sure B isn't able to clip through A's // Firstly we want to make sure B isn't able to clip through A's
// solid hitbox if A protests the movement. Trace a vector from // solid hitbox if A protests the movement. Trace a vector from
@ -187,38 +194,40 @@ func (w *Canvas) loopActorCollision() error {
// only return false if it protests the movement, but not trigger // only return false if it protests the movement, but not trigger
// any actions (such as emit messages to linked doodads) until // any actions (such as emit messages to linked doodads) until
// Settled=true. // Settled=true.
if origHitbox, ok := originalHitboxes[b.ID()]; ok { if origHitbox, ok := originalHitboxes[mover.ID()]; ok {
// Trace a vector back from the actor's current position
// to where they originated from. If A protests B's position at
// ANY time, we mark didProtest=true and continue backscanning
// B's movement. The next time A does NOT protest, that is to be
// B's new position.
// Special case for when a mobile actor lands ON TOP OF a solid
// actor. We want to stop their Y movement downwards, but allow
// horizontal movement on the X axis.
// Touching the solid actor from the side is already fine.
var onTop = false
var onBottom = false // they hit the bottom instead
var ( var (
lockX int // Special case for when a mobile actor lands ON TOP OF a solid
lockY int // actor. We want to stop their Y movement downwards, but allow
// horizontal movement on the X axis.
// Touching the solid actor from the side is already fine.
onTop bool
onBottom bool // they hit the bottom instead
// onSide bool // they hit a side, maybe allow Y movement
// If we lock their movement coordinate.
lockX *int
lockY *int
) )
// If their original hitbox is offset from their sprite corner, // If their original hitbox is offset from their sprite corner,
// gather the offset now. // gather the offset now.
var ( var (
origPosition = originalPositions[b.ID()] origPosition = originalPositions[mover.ID()]
hitboxPadding = render.Point{ hitboxPadding = render.Point{
X: render.AbsInt(origHitbox.X - origPosition.X), X: render.AbsInt(origHitbox.X - origPosition.X),
Y: render.AbsInt(origHitbox.Y - origPosition.Y), Y: render.AbsInt(origHitbox.Y - origPosition.Y),
} }
) )
// Trace a vector back from the mover's current position
// to where they originated from. If A protests B's position at
// ANY time, we ?mark didProtest=true? and continue backscanning
// B's movement. The next time A does NOT protest, that is to be
// B's new position.
for point := range render.IterLine( for point := range render.IterLine(
origHitbox.Point(), origHitbox.Point(),
b.Position(), // TODO: verify non 0,0 hitbox doodads work mover.Position(), // TODO: verify non 0,0 hitbox doodads work
) { ) {
point := point point := point
test := render.Rect{ test := render.Rect{
@ -233,16 +242,16 @@ func (w *Canvas) loopActorCollision() error {
// of their declared hitboxes (if smaller) to see if their hitboxes // of their declared hitboxes (if smaller) to see if their hitboxes
// intersect as well. // intersect as well.
var ( var (
aHitbox = collision.GetBoundingRectHitbox(a, a.Hitbox()) stableHitbox = collision.GetBoundingRectHitbox(stable, stable.Hitbox())
bHitbox = collision.GetBoundingRectHitbox(b, b.Hitbox()) moverHitbox = collision.GetBoundingRectHitbox(mover, mover.Hitbox())
) )
// B is overlapping A's box, call its OnCollide handler // B is overlapping A's box, call its OnCollide handler
// with Settled=false and see if it protests the overlap. // with Settled=false and see if it protests the overlap.
err := w.scripting.To(a.ID()).Events.RunCollide(&CollideEvent{ err := w.scripting.To(stable.ID()).Events.RunCollide(&CollideEvent{
Actor: b, Actor: mover,
Overlap: info.Overlap, Overlap: info.Overlap,
InHitbox: aHitbox.Intersects(bHitbox), InHitbox: stableHitbox.Intersects(moverHitbox),
Settled: false, Settled: false,
}) })
@ -252,65 +261,69 @@ func (w *Canvas) loopActorCollision() error {
if err == scripting.ErrReturnFalse { if err == scripting.ErrReturnFalse {
// Are they on top? // Are they on top?
var ( var (
aHitbox = collision.GetBoundingRectHitbox(a, a.Hitbox()) stableTop = stableHitbox.Y
bBottom = test.Y + test.H // bottom of falling actor stableBottom = stableHitbox.Y + stableHitbox.H
aTop = aHitbox.Y moverTop = test.Y
aBottom = aHitbox.Y + aHitbox.H moverBottom = test.Y + test.H // bottom of falling actor
bTop = test.Y
) )
// Is the colliding actor on top? (B=player character) // Is the colliding actor on top? (e.g. mover=player character)
if render.AbsInt(bBottom-aTop) < 4 { if render.AbsInt(moverBottom-stableTop) < balance.OnTopThreshold {
log.Error("ActorCollision: onTop=true at Y=%d", test.Y)
onTop = true onTop = true
onTopY = aHitbox.Y // onTopY = stableHitbox.Y
} }
// Or are they hitting from below? // Or are they hitting from below?
if render.AbsInt(aBottom-bTop) < 4 { if render.AbsInt(stableBottom-moverTop) < balance.OnTopThreshold {
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Error("ActorCollision: hit the bottom at Y=%d", test.Y)
onBottom = true onBottom = true
} }
if onTop || onBottom {
log.Error("onTop=%+v onBottom=%+v", onTop, onBottom)
}
// What direction were we moving? // What direction were we moving?
if test.Y != lastGoodBox.Y { if test.Y != lastGoodBox.Y {
if lockY == 0 {
lockY = lastGoodBox.Y // If we are hitting the top or bottom, lock our Y coordinate here.
if onBottom { if onTop || onBottom {
lockY = lastGoodBox.Y - hitboxPadding.Y
// First Y coordinate before the protested collision.
if lockY == nil {
lockY = new(int)
*lockY = lastGoodBox.Y
if onBottom {
*lockY -= hitboxPadding.Y
}
}
// If on top, set the mover to Grounded here.
if onTop {
mover.SetGrounded(true)
} }
log.Error("### Set LockY = %d", lockY)
} }
if onTop {
log.Error("ActorCollision: setGrounded(true) at Y=%d", test.Y)
b.SetGrounded(true)
}
} }
if test.X != lastGoodBox.X { if test.X != lastGoodBox.X {
if lockX == 0 && !(onTop || onBottom) { if lockX == nil && !(onTop || onBottom) {
// lockY = lastGoodBox.Y - (hitboxPadding.Y / 2) lockX = new(int)
lockX = lastGoodBox.X *lockX = lastGoodBox.X
} }
} }
// Move them back to the last good box. // Move them back to the last good box.
lastGoodBox = render.Rect{ lastGoodBox = render.Rect{
X: test.X - hitboxPadding.X, X: test.X, // - hitboxPadding.X, // note: this is in World Coordinates
Y: test.Y - hitboxPadding.Y, Y: test.Y, // - hitboxPadding.Y,
W: test.W, W: test.W,
H: test.H, H: test.H,
} }
if lockX != 0 { if lockX != nil {
// lockY = lastGoodBox.Y + hitboxPadding.Y lastGoodBox.X = *lockX - hitboxPadding.X
lastGoodBox.X = lockX - hitboxPadding.X
} }
} else { } else {
if err != nil { if err != nil {
log.Error("RunCollide on %s (%s) errored: %s", a.ID(), a.Actor.Filename, err) log.Error("RunCollide on %s (%s) errored: %s", stable.ID(), stable.Actor.Filename, err)
} }
// Move them back to the last good box. // Move them back to the last good box.
lastGoodBox = test lastGoodBox = test
@ -322,51 +335,52 @@ func (w *Canvas) loopActorCollision() error {
} }
// Did we lock their X or Y coordinate from moving further? // Did we lock their X or Y coordinate from moving further?
if lockY != 0 { if lockY != nil {
lastGoodBox.Y = lockY lastGoodBox.Y = *lockY
} }
if lockX != 0 { if lockX != nil {
lastGoodBox.X = lockX lastGoodBox.X = *lockX
} }
if !b.noclip { if !mover.noclip {
log.Error("Move B to: %s", lastGoodBox.Point()) log.Error("Move B to: %s", lastGoodBox.Point())
// The stationary doodad should move the moving one only. // The stationary doodad should move the moving one only.
b.MoveTo(lastGoodBox.Point()) mover.MoveTo(lastGoodBox.Point())
} }
} else { } else {
log.Error( log.Error(
"ERROR: Actors %s and %s overlap and the script returned false,"+ "ERROR: Actors %s and %s overlap and the script returned false,"+
"but I didn't store %s original position earlier??", "but I didn't store %s original position earlier??",
a.Doodad().Title, b.Doodad().Title, b.Doodad().Title, stable.Doodad().Title, mover.Doodad().Title, mover.Doodad().Title,
) )
} }
if onTopY != 0 && lastGoodBox.Y-onTopY <= 1 { // TODO: onTopY != nil
lastGoodBox.Y = onTopY // if onTopY != 0 && lastGoodBox.Y-onTopY <= 1 {
} // lastGoodBox.Y = onTopY
// }
// Movement has been settled. Check if B's point is still invading // Movement has been settled. Check if B's point is still invading
// A's box and call its OnCollide handler one last time in // A's box and call its OnCollide handler one last time in
// Settled=true mode so it can run its actions. // Settled=true mode so it can run its actions.
if info, err := collision.CompareBoxes(boxes[tuple.A], lastGoodBox); err == nil { if info, err := collision.CompareBoxes(boxes[tuple.A], lastGoodBox); err == nil {
if err := w.scripting.To(a.ID()).Events.RunCollide(&CollideEvent{ if err := w.scripting.To(stable.ID()).Events.RunCollide(&CollideEvent{
Actor: b, Actor: mover,
Overlap: info.Overlap, Overlap: info.Overlap,
InHitbox: info.Overlap.Intersects(a.Hitbox()), InHitbox: info.Overlap.Intersects(stable.Hitbox()),
Settled: true, Settled: true,
}); err != nil && err != scripting.ErrReturnFalse { }); err != nil && err != scripting.ErrReturnFalse {
log.Error("VM(%s).RunCollide: %s", a.ID(), err.Error()) log.Error("VM(%s).RunCollide: %s", stable.ID(), err.Error())
} }
// If the (player) is pressing the Use key, call the colliding // If the (player) is pressing the Use key, call the colliding
// actor's OnUse event. // actor's OnUse event.
if b.flagUsing { if mover.flagUsing {
if err := w.scripting.To(a.ID()).Events.RunUse(&UseEvent{ if err := w.scripting.To(stable.ID()).Events.RunUse(&UseEvent{
Actor: b, Actor: mover,
}); err != nil { }); err != nil {
log.Error("VM(%s).RunUse: %s", a.ID(), err.Error()) log.Error("VM(%s).RunUse: %s", stable.ID(), err.Error())
} }
} }
} }
@ -376,16 +390,52 @@ func (w *Canvas) loopActorCollision() error {
log.Warn("-- END BetweenBoxes") log.Warn("-- END BetweenBoxes")
// Check for lacks of collisions since last frame. // Check for lacks of collisions since last frame.
for sourceActor, targetActor := range w.collidingActors { // Note: w.collidingActors is "last frame's" map of colliding actor boxes.
if _, ok := collidingActors[sourceActor]; !ok { w.collidingActors.Iter(func(stable, mover *Actor) {
w.scripting.To(sourceActor.ID()).Events.RunLeave(&CollideEvent{
Actor: targetActor, // Are these not colliding this frame?
// TODO: does this work with three-way actor collisions?
if !collidingActors.Exists(stable, mover) {
w.scripting.To(stable.ID()).Events.RunLeave(&CollideEvent{
Actor: mover,
Settled: true, Settled: true,
}) })
} }
} })
// Store this frame's colliding actors for next frame. // Store this frame's colliding actors for next frame.
w.collidingActors = collidingActors w.collidingActors = collidingActors
return nil return nil
} }
// ActorCollisionMap keeps a cache of collision box overlaps between
// an Actor and one or more other Actors.
type ActorCollisionMap map[*Actor]map[*Actor]interface{}
// Set a collision to the other actor.
func (m ActorCollisionMap) Set(stable, mover *Actor) {
if m[stable] == nil {
m[stable] = map[*Actor]interface{}{}
}
m[stable][mover] = nil
}
// Exists checks if the actor is colliding with the other.
func (m ActorCollisionMap) Exists(stable, mover *Actor) bool {
if m[stable] == nil {
return false
}
_, ok := m[stable][mover]
return ok
}
// Iter the collision data.
func (m ActorCollisionMap) Iter(fn func(stable, mover *Actor)) {
for stable, moverMap := range m {
for mover := range moverMap {
fn(stable, mover)
}
}
}

View File

@ -90,7 +90,7 @@ type Canvas struct {
actors []*Actor // if this canvas CONTAINS actors (i.e., is a level) actors []*Actor // if this canvas CONTAINS actors (i.e., is a level)
// Collision memory for the actors. // Collision memory for the actors.
collidingActors map[*Actor]*Actor // mapping their IDs to each other collidingActors ActorCollisionMap // mapping their IDs to each other
// Doodad scripting engine supervisor. // Doodad scripting engine supervisor.
// NOTE: initialized and managed by the play_scene. // NOTE: initialized and managed by the play_scene.