doodle/pkg/uix/actor_collision.go
Noah Petherbridge 0e3a30e633 Fix Actor Collision Checks Again
* Recent collision update caused a regression where the player would get
  "stuck" while standing on top of a solid doodad, unable to walk left
  or right.
* When deciding if the actor is on top of a doodad, use the doodad's
  Hitbox (if available) instead of the bounding box. This fixes the
  upside-down trapdoor acting solid when landed on from the top, since
  its Hitbox Y coordinate is not the same as the top of its sprite.
* Cheats: when using the noclip cheat in Play Mode, you can hold down
  the Shift key while moving to only move one pixel at a time.
2020-01-02 22:05:49 -08:00

279 lines
8.2 KiB
Go

package uix
import (
"errors"
"sync"
"time"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/collision"
"git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/scripting"
"git.kirsle.net/go/render"
"github.com/robertkrimen/otto"
)
// loopActorCollision is the Loop function that checks if pairs of
// actors are colliding with each other, and handles their scripting
// responses to such collisions.
func (w *Canvas) loopActorCollision() error {
if w.scripting == nil {
return errors.New("Canvas.loopActorCollision: scripting engine not attached to Canvas")
}
var (
// Current time of this tick so we can advance animations.
now = time.Now()
// As we iterate over all actors below to process their movement, track
// their bounding rectangles so we can later see if any pair of actors
// intersect each other. Also, in case of actor scripts protesting a
// collision later, store each actor's original position before the move.
boxes = make([]render.Rect, len(w.actors))
originalPositions = map[string]render.Point{}
)
// Loop over all the actors in parallel, processing their movement and
// checking collision data against the level geometry.
var wg sync.WaitGroup
for i, a := range w.actors {
wg.Add(1)
go func(i int, a *Actor) {
defer wg.Done()
originalPositions[a.ID()] = a.Position()
// Advance any animations for this actor.
if a.activeAnimation != nil && a.activeAnimation.nextFrameAt.Before(now) {
if done := a.TickAnimation(a.activeAnimation); done {
// Animation has finished, get the callback function.
callback := a.animationCallback
// Clean up the animation state, in case the callback wants
// to immediately play another animation.
a.StopAnimation()
// Call the callback function.
if callback.IsFunction() {
callback.Call(otto.NullValue())
}
}
}
// Get the actor's velocity to see if it's moving this tick.
v := a.Velocity()
if a.hasGravity && v.Y >= 0 {
v.Y += balance.Gravity
}
// If not moving, grab the bounding box right now.
if v == render.Origin {
boxes[i] = doodads.GetBoundingRect(a)
return
}
// Create a delta point from their current location to where they
// want to move to this tick.
delta := a.Position()
delta.Add(v)
// Check collision with level geometry.
info, ok := collision.CollidesWithGrid(a, w.chunks, delta)
if ok {
// Collision happened with world.
if w.OnLevelCollision != nil {
w.OnLevelCollision(a, info)
}
}
// Move us back where the collision check put us
if !a.noclip {
delta = info.MoveTo
}
// Move the actor's World Position to the new location.
a.MoveTo(delta)
// Keep the actor from leaving the world borders of bounded maps.
w.loopContainActorsInsideLevel(a)
// Store this actor's bounding box after they've moved.
boxes[i] = doodads.GetBoundingRect(a)
}(i, a)
wg.Wait()
}
var collidingActors = map[string]string{}
for tuple := range collision.BetweenBoxes(boxes) {
a, b := w.actors[tuple.A], w.actors[tuple.B]
// If neither actor is mobile, don't run collision handlers.
if !(a.IsMobile() || b.IsMobile()) {
continue
}
collidingActors[a.ID()] = b.ID()
// Call the OnCollide handler for A informing them of B's intersection.
if w.scripting != nil {
var (
rect = doodads.GetBoundingRect(b)
lastGoodBox = render.Rect{
X: originalPositions[b.ID()].X,
Y: originalPositions[b.ID()].Y,
W: boxes[tuple.B].W,
H: boxes[tuple.B].H,
}
)
// HACK: below, when we determine the moving actor is "onTop" of
// the doodad's solid hitbox, we lockY their movement so they don't
// fall down further; but sometimes there's an off-by-one error if
// the actor fell a distance before landing, and so the final
// Settled collision check doesn't fire (i.e. if they fell onto a
// Crumbly Floor which should begin shaking when walked on).
//
// When we decide they're onTop, record the Y position, and then
// use it for collision-check purposes but DON'T physically move
// the character by it (moving the character may clip them thru
// other solid hitboxes like the upside-down trapdoor)
var onTopY int
// 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
// B's original position to their current one and ping A's
// OnCollide handler for each step, with Settled=false. A should
// only return false if it protests the movement, but not trigger
// any actions (such as emit messages to linked doodads) until
// Settled=true.
if origPoint, ok := originalPositions[b.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 (
lockX int
lockY int
)
for point := range render.IterLine(
origPoint,
b.Position(),
) {
point := point
test := render.Rect{
X: point.X,
Y: point.Y,
W: rect.W,
H: rect.H,
}
if info, err := collision.CompareBoxes(boxes[tuple.A], test); err == nil {
// B is overlapping A's box, call its OnCollide handler
// with Settled=false and see if it protests the overlap.
err := w.scripting.To(a.ID()).Events.RunCollide(&CollideEvent{
Actor: b,
Overlap: info.Overlap,
InHitbox: info.Overlap.Intersects(a.Hitbox()),
Settled: false,
})
// Did A protest?
if err == scripting.ErrReturnFalse {
// Are they on top?
aHitbox := doodads.GetBoundingRectHitbox(a, a.Hitbox())
if render.AbsInt(test.Y+test.H-aHitbox.Y) == 0 {
onTop = true
onTopY = test.Y
}
// What direction were we moving?
if test.Y != lastGoodBox.Y {
if lockY == 0 {
lockY = lastGoodBox.Y
}
if onTop {
b.SetGrounded(true)
}
}
if test.X != lastGoodBox.X {
if !onTop {
lockX = lastGoodBox.X
}
}
// Move them back to the last good box.
lastGoodBox = test
if lockX != 0 {
lastGoodBox.X = lockX
}
} else {
// Move them back to the last good box.
lastGoodBox = test
}
} else {
// No collision between boxes, increment the lastGoodBox
lastGoodBox = test
}
}
// Did we lock their X or Y coordinate from moving further?
if lockY != 0 {
lastGoodBox.Y = lockY
}
if lockX != 0 {
lastGoodBox.X = lockX
}
if !b.noclip {
b.MoveTo(lastGoodBox.Point())
}
} else {
log.Error(
"ERROR: Actors %s and %s overlap and the script returned false,"+
"but I didn't store %s original position earlier??",
a.Doodad.Title, b.Doodad.Title, b.Doodad.Title,
)
}
if onTopY != 0 && lastGoodBox.Y-onTopY <= 1 {
lastGoodBox.Y = onTopY
}
// Movement has been settled. Check if B's point is still invading
// A's box and call its OnCollide handler one last time in
// Settled=true mode so it can run its actions.
if info, err := collision.CompareBoxes(boxes[tuple.A], lastGoodBox); err == nil {
if err := w.scripting.To(a.ID()).Events.RunCollide(&CollideEvent{
Actor: b,
Overlap: info.Overlap,
InHitbox: info.Overlap.Intersects(a.Hitbox()),
Settled: true,
}); err != nil && err != scripting.ErrReturnFalse {
log.Error("VM(%s).RunCollide: %s", a.ID(), err.Error())
}
}
}
}
// Check for lacks of collisions since last frame.
for sourceID, targetID := range w.collidingActors {
if _, ok := collidingActors[sourceID]; !ok {
w.scripting.To(sourceID).Events.RunLeave(targetID)
}
}
// Store this frame's colliding actors for next frame.
w.collidingActors = collidingActors
return nil
}