doodle/pkg/uix/actor_collision.go
Noah Petherbridge 618d4b07c5 WIP: Fix actor collisions with offset hitboxes
When an actor's Hitbox doesn't begin at 0,0 the collision checks between
boxes is buggy. Current progress:

* Player is a 32x64 size sprite with a hitbox of 0,32 32x32 (bottom half)
* Landing onTop works
* Hitting onBottom works
* Bug: colliding from the side currently pushes the player 32px down
  into the floor. With non-offset doodads walking sideways into e.g. a
  locked door halts the X and Y movement until you let go, but offset
  doodads get pushed down mysteriously.
2024-05-28 22:36:53 -07:00

392 lines
12 KiB
Go

package uix
import (
"errors"
"time"
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
"git.kirsle.net/SketchyMaze/doodle/pkg/collision"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/physics"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting"
"git.kirsle.net/go/render"
"github.com/dop251/goja"
)
// 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{}
originalHitboxes = map[string]render.Rect{} // original world hitboxes
)
// Loop over all the actors in parallel, processing their movement and
// checking collision data against the level geometry.
// NOTE: parallelism wasn't good for race conditions like the Thief
// trying to take your inventory.
// var wg sync.WaitGroup
for i, a := range w.actors {
if a.IsFrozen() {
continue
}
// wg.Add(1)
//go
func(i int, a *Actor) {
// defer wg.Done()
originalPositions[a.ID()] = a.Position()
originalHitboxes[a.ID()] = collision.GetBoundingRectHitbox(a, a.Hitbox())
// 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 function, ok := goja.AssertFunction(callback); ok {
function(goja.Undefined())
}
}
}
// Get the actor's velocity to see if it's moving this tick.
v := a.Velocity()
// Apply gravity to the actor's velocity.
if a.hasGravity && !a.Grounded() { //v.Y >= 0 {
if !a.Grounded() {
var (
gravity = balance.GravityMaximum
acceleration = balance.GravityAcceleration
)
if a.IsWet() {
gravity = balance.SwimGravity
}
// If the actor is jumping/moving upwards, apply softer gravity.
if v.Y < 0 {
acceleration = balance.GravityJumpAcceleration
}
v.Y = physics.Lerp(
v.Y, // current speed
gravity, // target max gravity falling downwards
acceleration,
)
} else {
v.Y = 0
}
a.SetVelocity(v)
// v.Y += balance.Gravity
}
// If not moving, grab the bounding box right now.
if v.IsZero() {
boxes[i] = collision.GetBoundingRect(a)
return
}
// Create a delta point from their current location to where they
// want to move to this tick.
delta := physics.VectorFromPoint(a.Position())
delta.Add(v)
// Check collision with level geometry.
chkPoint := delta.ToPoint()
info, _ := collision.CollidesWithGrid(a, w.chunks, chkPoint)
// Inform the caller about the collision state every tick
if w.OnLevelCollision != nil {
w.OnLevelCollision(a, info)
}
// Move us back where the collision check put us
if !a.noclip {
delta = physics.VectorFromPoint(info.MoveTo)
}
// Move the actor's World Position to the new location.
a.MoveTo(delta.ToPoint())
// 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] = collision.GetBoundingRect(a)
}(i, a)
// wg.Wait()
}
// log.Warn("== BEGIN BetweenBoxes")
var collidingActors = map[*Actor]*Actor{}
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] = b
log.Error("between boxes: %+v A=<%s> B=<%s>", tuple, a.ID(), b.ID())
// Call the OnCollide handler for A informing them of B's intersection.
if w.scripting != nil {
var (
rect = collision.GetBoundingRectHitbox(b, b.Hitbox())
// lastGoodBox = rect
lastGoodBox = render.Rect{
// Level Positions of the doodad is based on the top left
// of its graphical sprite, not its (possibly offset) hitbox.
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 origHitbox, ok := originalHitboxes[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 onBottom = false // they hit the bottom instead
var (
lockX int
lockY int
)
// If their original hitbox is offset from their sprite corner,
// gather the offset now.
var (
origPosition = originalPositions[b.ID()]
hitboxPadding = render.Point{
X: render.AbsInt(origHitbox.X - origPosition.X),
Y: render.AbsInt(origHitbox.Y - origPosition.Y),
}
)
for point := range render.IterLine(
origHitbox.Point(),
b.Position(), // TODO: verify non 0,0 hitbox doodads work
) {
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 {
// A and B have their drawings overlapping on the page. Get each
// of their declared hitboxes (if smaller) to see if their hitboxes
// intersect as well.
var (
aHitbox = collision.GetBoundingRectHitbox(a, a.Hitbox())
bHitbox = collision.GetBoundingRectHitbox(b, b.Hitbox())
)
// 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: aHitbox.Intersects(bHitbox),
Settled: false,
})
// log.Warn("ActorCollision: CompareBoxes info was %+v", info)
// Did A protest?
if err == scripting.ErrReturnFalse {
// Are they on top?
var (
aHitbox = collision.GetBoundingRectHitbox(a, a.Hitbox())
bBottom = test.Y + test.H // bottom of falling actor
aTop = aHitbox.Y
aBottom = aHitbox.Y + aHitbox.H
bTop = test.Y
)
// Is the colliding actor on top? (B=player character)
if render.AbsInt(bBottom-aTop) < 4 {
log.Error("ActorCollision: onTop=true at Y=%d", test.Y)
onTop = true
onTopY = aHitbox.Y
}
// Or are they hitting from below?
if render.AbsInt(aBottom-bTop) < 4 {
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Info("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&")
log.Error("ActorCollision: hit the bottom at Y=%d", test.Y)
onBottom = true
}
// What direction were we moving?
if test.Y != lastGoodBox.Y {
if lockY == 0 {
lockY = lastGoodBox.Y
if onBottom {
lockY = lastGoodBox.Y - hitboxPadding.Y
}
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 lockX == 0 && !(onTop || onBottom) {
// lockY = lastGoodBox.Y - (hitboxPadding.Y / 2)
lockX = lastGoodBox.X
}
}
// Move them back to the last good box.
lastGoodBox = render.Rect{
X: test.X - hitboxPadding.X,
Y: test.Y - hitboxPadding.Y,
W: test.W,
H: test.H,
}
if lockX != 0 {
// lockY = lastGoodBox.Y + hitboxPadding.Y
lastGoodBox.X = lockX - hitboxPadding.X
}
} 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.
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 {
log.Error("Move B to: %s", lastGoodBox.Point())
// The stationary doodad should move the moving one only.
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())
}
// If the (player) is pressing the Use key, call the colliding
// actor's OnUse event.
if b.flagUsing {
if err := w.scripting.To(a.ID()).Events.RunUse(&UseEvent{
Actor: b,
}); err != nil {
log.Error("VM(%s).RunUse: %s", a.ID(), err.Error())
}
}
}
}
}
log.Warn("-- END BetweenBoxes")
// Check for lacks of collisions since last frame.
for sourceActor, targetActor := range w.collidingActors {
if _, ok := collidingActors[sourceActor]; !ok {
w.scripting.To(sourceActor.ID()).Events.RunLeave(&CollideEvent{
Actor: targetActor,
Settled: true,
})
}
}
// Store this frame's colliding actors for next frame.
w.collidingActors = collidingActors
return nil
}