Better Collision Detection (Bouncy Jumps Up Hills)

* Add a debug view that draws the player bounding boxes.
* Improve the collision detection to add support for:
  * Doodads being "Grounded" so gravity need not apply.
  * Walking up hills, albeit a bit "bouncily"
  * Harder to clip out of bounds
This commit is contained in:
Noah 2018-07-24 20:57:22 -07:00
parent c3fd2e63cb
commit d560670b7b
6 changed files with 281 additions and 91 deletions

View File

@ -1,6 +1,8 @@
package doodads package doodads
import ( import (
"fmt"
"git.kirsle.net/apps/doodle/level" "git.kirsle.net/apps/doodle/level"
"git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/render"
) )
@ -15,6 +17,8 @@ type Doodad interface {
Position() render.Point Position() render.Point
Velocity() render.Point Velocity() render.Point
Size() render.Rect Size() render.Rect
Grounded() bool
SetGrounded(bool)
// Movement commands. // Movement commands.
MoveBy(render.Point) // Add {X,Y} to current Position. MoveBy(render.Point) // Add {X,Y} to current Position.
@ -26,89 +30,231 @@ type Doodad interface {
// Collide describes how a collision occurred. // Collide describes how a collision occurred.
type Collide struct { type Collide struct {
X int32 Top bool
Y int32 TopPoint render.Point
W int32 Left bool
H int32 LeftPoint render.Point
Top bool Right bool
Left bool RightPoint render.Point
Right bool Bottom bool
Bottom bool BottomPoint render.Point
MoveTo render.Point
} }
// CollisionBox holds all of the coordinate pairs to draw the collision box
// around a doodad.
type CollisionBox struct {
Top []render.Point
Bottom []render.Point
Left []render.Point
Right []render.Point
}
// Side of the collision box (top, bottom, left, right)
type Side uint8
// Options for the Side type.
const (
Top Side = iota
Bottom
Left
Right
)
// CollidesWithGrid checks if a Doodad collides with level geometry. // CollidesWithGrid checks if a Doodad collides with level geometry.
func CollidesWithGrid(d Doodad, grid *render.Grid) (Collide, bool) { func CollidesWithGrid(d Doodad, grid *render.Grid, target render.Point) (*Collide, bool) {
var ( var (
P = d.Position() P = d.Position()
S = d.Size() S = d.Size()
topLeft = P
topRight = render.Point{ result = &Collide{
X: P.X + S.W, MoveTo: P,
Y: P.Y,
}
bottomLeft = render.Point{
X: P.X,
Y: P.Y + S.H,
}
bottomRight = render.Point{
X: bottomLeft.X + S.W,
Y: P.Y + S.H,
} }
) )
// Bottom edge. // Test all of the bounding boxes for a collision with level geometry.
for point := range render.IterLine2(bottomLeft, bottomRight) { if ok := result.ScanBoundingBox(GetBoundingRect(d), grid); ok {
if grid.Exists(level.Pixel{ // We've already collided! Try to wiggle free.
X: point.X, if result.Bottom {
Y: point.Y, if !d.Grounded() {
}) { d.SetGrounded(true)
return Collide{ } else {
Bottom: true, // result.Bottom = false
X: point.X, }
Y: point.Y, } else {
}, true d.SetGrounded(false)
}
if result.Top {
P.Y++
}
if result.Left {
P.X++
}
if result.Right {
P.X--
} }
} }
// Top edge. // If grounded, cap our Y position.
for point := range render.IterLine2(topLeft, topRight) { if d.Grounded() {
if grid.Exists(level.Pixel{ if !result.Bottom {
X: point.X, // We've fallen off a ledge.
Y: point.Y, d.SetGrounded(false)
}) { } else if target.Y < P.Y {
return Collide{ // We're moving upward.
Top: true, d.SetGrounded(false)
X: point.X, } else {
Y: point.Y, // Cap our downward motion to our current position.
}, true target.Y = P.Y
} }
} }
for point := range render.IterLine2(topLeft, bottomLeft) { // Cap our horizontal movement if we're touching walls.
if grid.Exists(level.Pixel{ if (result.Left && target.X < P.X) || (result.Right && target.X > P.X) {
X: point.X, // If the step is short enough, try and jump up.
Y: point.Y, relPoint := P.Y + S.H
}) { if result.Left && target.X < P.X {
return Collide{ relPoint -= result.LeftPoint.Y
Left: true, } else {
X: point.X, relPoint -= result.RightPoint.Y
Y: point.Y, }
}, true fmt.Printf("Touched a wall at %d pixels height (P=%s)\n", relPoint, P)
if S.H-relPoint > S.H-8 {
target.Y -= 12
if target.X < P.X {
target.X-- // push along to the left
} else if target.X > P.X {
target.X++ // push along to the right
}
} else {
target.X = P.X
} }
} }
for point := range render.IterLine2(topRight, bottomRight) { // Trace a line from where we are to where we wanna go.
if grid.Exists(level.Pixel{ result.MoveTo = P
for point := range render.IterLine2(P, target) {
if ok := result.ScanBoundingBox(render.Rect{
X: point.X, X: point.X,
Y: point.Y, Y: point.Y,
}) { W: S.W,
return Collide{ H: S.H,
Right: true, }, grid); ok {
X: point.X, if d.Grounded() {
Y: point.Y, if !result.Bottom {
}, true d.SetGrounded(false)
}
} else if result.Bottom {
d.SetGrounded(true)
}
} }
result.MoveTo = point
} }
return Collide{}, false return result, result.IsColliding()
}
// IsColliding returns whether any sort of collision has occurred.
func (c *Collide) IsColliding() bool {
return c.Top || c.Bottom || c.Left || c.Right
}
// GetCollisionBox computes the full pairs of points for the collision box
// around a doodad.
func GetBoundingRect(d Doodad) render.Rect {
var (
P = d.Position()
S = d.Size()
)
return render.Rect{
X: P.X,
Y: P.Y,
W: S.W,
H: S.H,
}
}
func GetCollisionBox(box render.Rect) CollisionBox {
return CollisionBox{
Top: []render.Point{
{
X: box.X,
Y: box.Y,
},
{
X: box.X + box.W,
Y: box.Y,
},
},
Bottom: []render.Point{
{
X: box.X,
Y: box.Y + box.H,
},
{
X: box.X + box.W,
Y: box.Y + box.H,
},
},
Left: []render.Point{
{
X: box.X,
Y: box.Y + 1,
},
{
X: box.X,
Y: box.Y + box.H - 1,
},
},
Right: []render.Point{
{
X: box.X + box.W,
Y: box.Y + 1,
},
{
X: box.X + box.W,
Y: box.Y + box.H - 1,
},
},
}
}
// ScanBoundingBox scans all of the pixels in a bounding box on the grid and
// returns if any of them intersect with level geometry.
func (c *Collide) ScanBoundingBox(box render.Rect, grid *render.Grid) bool {
col := GetCollisionBox(box)
c.ScanGridLine(col.Top[0], col.Top[1], grid, Top)
c.ScanGridLine(col.Bottom[0], col.Bottom[1], grid, Bottom)
c.ScanGridLine(col.Left[0], col.Left[1], grid, Left)
c.ScanGridLine(col.Right[0], col.Right[1], grid, Right)
return c.IsColliding()
}
// ScanGridLine scans all of the pixels between p1 and p2 on the grid and tests
// for any pixels to be set, implying a collision between level geometry and the
// bounding boxes of the doodad.
func (c *Collide) ScanGridLine(p1, p2 render.Point, grid *render.Grid, side Side) {
for point := range render.IterLine2(p1, p2) {
if grid.Exists(level.Pixel{
X: point.X,
Y: point.Y,
}) {
// A hit!
switch side {
case Top:
c.Top = true
c.TopPoint = point
case Bottom:
c.Bottom = true
c.BottomPoint = point
case Left:
c.Left = true
c.LeftPoint = point
case Right:
c.Right = true
c.RightPoint = point
}
}
}
} }

View File

@ -12,6 +12,7 @@ type Player struct {
point render.Point point render.Point
velocity render.Point velocity render.Point
size render.Rect size render.Rect
grounded bool
} }
// NewPlayer creates the special Player Character doodad. // NewPlayer creates the special Player Character doodad.
@ -22,8 +23,8 @@ func NewPlayer() *Player {
Y: 100, Y: 100,
}, },
size: render.Rect{ size: render.Rect{
W: 16, W: 32,
H: 16, H: 32,
}, },
} }
} }
@ -59,9 +60,19 @@ func (p *Player) Size() render.Rect {
return p.size return p.size
} }
// Grounded returns if the player is grounded.
func (p *Player) Grounded() bool {
return p.grounded
}
// SetGrounded sets if the player is grounded.
func (p *Player) SetGrounded(v bool) {
p.grounded = v
}
// Draw the player sprite. // Draw the player sprite.
func (p *Player) Draw(e render.Engine) { func (p *Player) Draw(e render.Engine) {
e.DrawRect(render.Magenta, render.Rect{ e.DrawBox(render.Color{255, 255, 153, 255}, render.Rect{
X: p.point.X, X: p.point.X,
Y: p.point.Y, Y: p.point.Y,
W: p.size.W, W: p.size.W,

14
fps.go
View File

@ -3,6 +3,7 @@ package doodle
import ( import (
"fmt" "fmt"
"git.kirsle.net/apps/doodle/doodads"
"git.kirsle.net/apps/doodle/render" "git.kirsle.net/apps/doodle/render"
) )
@ -49,6 +50,19 @@ func (d *Doodle) DrawDebugOverlay() {
} }
} }
// DrawCollisionBox draws the collision box around a Doodad.
func (d *Doodle) DrawCollisionBox(actor doodads.Doodad) {
var (
rect = doodads.GetBoundingRect(actor)
box = doodads.GetCollisionBox(rect)
)
d.Engine.DrawLine(render.DarkGreen, box.Top[0], box.Top[1])
d.Engine.DrawLine(render.DarkBlue, box.Bottom[0], box.Bottom[1])
d.Engine.DrawLine(render.DarkYellow, box.Left[0], box.Left[1])
d.Engine.DrawLine(render.Red, box.Right[0], box.Right[1])
}
// TrackFPS shows the current FPS once per second. // TrackFPS shows the current FPS once per second.
func (d *Doodle) TrackFPS(skipped uint32) { func (d *Doodle) TrackFPS(skipped uint32) {
fpsFrames++ fpsFrames++

View File

@ -82,6 +82,9 @@ func (s *PlayScene) Draw(d *Doodle) error {
// Draw our hero. // Draw our hero.
s.player.Draw(d.Engine) s.player.Draw(d.Engine)
// Draw out bounding boxes.
d.DrawCollisionBox(s.player)
return nil return nil
} }
@ -105,20 +108,20 @@ func (s *PlayScene) movePlayer(ev *events.State) {
} }
// Apply gravity. // Apply gravity.
delta.Y += gravity // var onFloor bool
// Draw a ray and check for collision. info, ok := doodads.CollidesWithGrid(s.player, &s.canvas, delta)
var lastOk = s.player.Position() if ok {
for point := range render.IterLine2(s.player.Position(), delta) { // Collision happened with world.
s.player.MoveTo(point) }
if _, ok := doodads.CollidesWithGrid(s.player, &s.canvas); ok { delta = info.MoveTo
s.player.MoveTo(lastOk)
} else { // Apply gravity if not grounded.
lastOk = s.player.Position() if !s.player.Grounded() {
} delta.Y += gravity
} }
s.player.MoveTo(lastOk) s.player.MoveTo(delta)
} }
// LoadLevel loads a level from disk. // LoadLevel loads a level from disk.

View File

@ -45,6 +45,16 @@ type Color struct {
Alpha uint8 Alpha uint8
} }
// RGBA creates a new Color.
func RGBA(r, g, b, a uint8) Color {
return Color{
Red: r,
Green: g,
Blue: b,
Alpha: a,
}
}
func (c Color) String() string { func (c Color) String() string {
return fmt.Sprintf( return fmt.Sprintf(
"Color<#%02x%02x%02x>", "Color<#%02x%02x%02x>",
@ -91,18 +101,24 @@ func (t Text) String() string {
// Common color names. // Common color names.
var ( var (
Invisible = Color{} Invisible = Color{}
White = Color{255, 255, 255, 255} White = RGBA(255, 255, 255, 255)
Grey = Color{153, 153, 153, 255} Grey = RGBA(153, 153, 153, 255)
Black = Color{0, 0, 0, 255} Black = RGBA(0, 0, 0, 255)
SkyBlue = Color{0, 153, 255, 255} SkyBlue = RGBA(0, 153, 255, 255)
Blue = Color{0, 0, 255, 255} Blue = RGBA(0, 0, 255, 255)
Red = Color{255, 0, 0, 255} DarkBlue = RGBA(0, 0, 153, 255)
Green = Color{0, 255, 0, 255} Red = RGBA(255, 0, 0, 255)
Cyan = Color{0, 255, 255, 255} DarkRed = RGBA(153, 0, 0, 255)
Yellow = Color{255, 255, 0, 255} Green = RGBA(0, 255, 0, 255)
Magenta = Color{255, 0, 255, 255} DarkGreen = RGBA(0, 153, 0, 255)
Pink = Color{255, 153, 255, 255} Cyan = RGBA(0, 255, 255, 255)
DarkCyan = RGBA(0, 153, 153, 255)
Yellow = RGBA(255, 255, 0, 255)
DarkYellow = RGBA(153, 153, 0, 255)
Magenta = RGBA(255, 0, 255, 255)
Purple = RGBA(153, 0, 153, 255)
Pink = RGBA(255, 153, 255, 255)
) )
// IterLine is a generator that returns the X,Y coordinates to draw a line. // IterLine is a generator that returns the X,Y coordinates to draw a line.

View File

@ -25,7 +25,7 @@ func (r *Renderer) DrawPoint(color render.Color, point render.Point) {
// DrawLine draws a line between two points. // DrawLine draws a line between two points.
func (r *Renderer) DrawLine(color render.Color, a, b render.Point) { func (r *Renderer) DrawLine(color render.Color, a, b render.Point) {
if color != r.lastColor { if color != r.lastColor {
r.renderer.SetDrawColor(color.Red, color.Blue, color.Green, color.Alpha) r.renderer.SetDrawColor(color.Red, color.Green, color.Blue, color.Alpha)
} }
r.renderer.DrawLine(a.X, a.Y, b.X, b.Y) r.renderer.DrawLine(a.X, a.Y, b.X, b.Y)
} }