From d560670b7b9ed131cf33e791f49341828a2d16a2 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 24 Jul 2018 20:57:22 -0700 Subject: [PATCH] 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 --- doodads/doodads.go | 274 +++++++++++++++++++++++++++++++++---------- doodads/player.go | 17 ++- fps.go | 14 +++ play_scene.go | 25 ++-- render/interface.go | 40 +++++-- render/sdl/canvas.go | 2 +- 6 files changed, 281 insertions(+), 91 deletions(-) diff --git a/doodads/doodads.go b/doodads/doodads.go index 454e045..aaef15c 100644 --- a/doodads/doodads.go +++ b/doodads/doodads.go @@ -1,6 +1,8 @@ package doodads import ( + "fmt" + "git.kirsle.net/apps/doodle/level" "git.kirsle.net/apps/doodle/render" ) @@ -15,6 +17,8 @@ type Doodad interface { Position() render.Point Velocity() render.Point Size() render.Rect + Grounded() bool + SetGrounded(bool) // Movement commands. MoveBy(render.Point) // Add {X,Y} to current Position. @@ -26,89 +30,231 @@ type Doodad interface { // Collide describes how a collision occurred. type Collide struct { - X int32 - Y int32 - W int32 - H int32 - Top bool - Left bool - Right bool - Bottom bool + Top bool + TopPoint render.Point + Left bool + LeftPoint render.Point + Right bool + RightPoint render.Point + 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. -func CollidesWithGrid(d Doodad, grid *render.Grid) (Collide, bool) { +func CollidesWithGrid(d Doodad, grid *render.Grid, target render.Point) (*Collide, bool) { var ( - P = d.Position() - S = d.Size() - topLeft = P - topRight = render.Point{ - X: P.X + S.W, - 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, + P = d.Position() + S = d.Size() + + result = &Collide{ + MoveTo: P, } ) - // Bottom edge. - for point := range render.IterLine2(bottomLeft, bottomRight) { - if grid.Exists(level.Pixel{ - X: point.X, - Y: point.Y, - }) { - return Collide{ - Bottom: true, - X: point.X, - Y: point.Y, - }, true + // Test all of the bounding boxes for a collision with level geometry. + if ok := result.ScanBoundingBox(GetBoundingRect(d), grid); ok { + // We've already collided! Try to wiggle free. + if result.Bottom { + if !d.Grounded() { + d.SetGrounded(true) + } else { + // result.Bottom = false + } + } else { + d.SetGrounded(false) + } + if result.Top { + P.Y++ + } + if result.Left { + P.X++ + } + if result.Right { + P.X-- } } - // Top edge. - for point := range render.IterLine2(topLeft, topRight) { - if grid.Exists(level.Pixel{ - X: point.X, - Y: point.Y, - }) { - return Collide{ - Top: true, - X: point.X, - Y: point.Y, - }, true + // If grounded, cap our Y position. + if d.Grounded() { + if !result.Bottom { + // We've fallen off a ledge. + d.SetGrounded(false) + } else if target.Y < P.Y { + // We're moving upward. + d.SetGrounded(false) + } else { + // Cap our downward motion to our current position. + target.Y = P.Y } } - for point := range render.IterLine2(topLeft, bottomLeft) { - if grid.Exists(level.Pixel{ - X: point.X, - Y: point.Y, - }) { - return Collide{ - Left: true, - X: point.X, - Y: point.Y, - }, true + // Cap our horizontal movement if we're touching walls. + if (result.Left && target.X < P.X) || (result.Right && target.X > P.X) { + // If the step is short enough, try and jump up. + relPoint := P.Y + S.H + if result.Left && target.X < P.X { + relPoint -= result.LeftPoint.Y + } else { + relPoint -= result.RightPoint.Y + } + 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) { - if grid.Exists(level.Pixel{ + // Trace a line from where we are to where we wanna go. + result.MoveTo = P + for point := range render.IterLine2(P, target) { + if ok := result.ScanBoundingBox(render.Rect{ X: point.X, Y: point.Y, - }) { - return Collide{ - Right: true, - X: point.X, - Y: point.Y, - }, true + W: S.W, + H: S.H, + }, grid); ok { + if d.Grounded() { + if !result.Bottom { + 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 + } + } + } } diff --git a/doodads/player.go b/doodads/player.go index 3f3372f..baee1a6 100644 --- a/doodads/player.go +++ b/doodads/player.go @@ -12,6 +12,7 @@ type Player struct { point render.Point velocity render.Point size render.Rect + grounded bool } // NewPlayer creates the special Player Character doodad. @@ -22,8 +23,8 @@ func NewPlayer() *Player { Y: 100, }, size: render.Rect{ - W: 16, - H: 16, + W: 32, + H: 32, }, } } @@ -59,9 +60,19 @@ func (p *Player) Size() render.Rect { 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. 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, Y: p.point.Y, W: p.size.W, diff --git a/fps.go b/fps.go index b46db71..d3c9ec3 100644 --- a/fps.go +++ b/fps.go @@ -3,6 +3,7 @@ package doodle import ( "fmt" + "git.kirsle.net/apps/doodle/doodads" "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. func (d *Doodle) TrackFPS(skipped uint32) { fpsFrames++ diff --git a/play_scene.go b/play_scene.go index c63131e..faaaf25 100644 --- a/play_scene.go +++ b/play_scene.go @@ -82,6 +82,9 @@ func (s *PlayScene) Draw(d *Doodle) error { // Draw our hero. s.player.Draw(d.Engine) + // Draw out bounding boxes. + d.DrawCollisionBox(s.player) + return nil } @@ -105,20 +108,20 @@ func (s *PlayScene) movePlayer(ev *events.State) { } // Apply gravity. - delta.Y += gravity + // var onFloor bool - // Draw a ray and check for collision. - var lastOk = s.player.Position() - for point := range render.IterLine2(s.player.Position(), delta) { - s.player.MoveTo(point) - if _, ok := doodads.CollidesWithGrid(s.player, &s.canvas); ok { - s.player.MoveTo(lastOk) - } else { - lastOk = s.player.Position() - } + info, ok := doodads.CollidesWithGrid(s.player, &s.canvas, delta) + if ok { + // Collision happened with world. + } + delta = info.MoveTo + + // Apply gravity if not grounded. + if !s.player.Grounded() { + delta.Y += gravity } - s.player.MoveTo(lastOk) + s.player.MoveTo(delta) } // LoadLevel loads a level from disk. diff --git a/render/interface.go b/render/interface.go index f592b84..d048b0a 100644 --- a/render/interface.go +++ b/render/interface.go @@ -45,6 +45,16 @@ type Color struct { 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 { return fmt.Sprintf( "Color<#%02x%02x%02x>", @@ -91,18 +101,24 @@ func (t Text) String() string { // Common color names. var ( - Invisible = Color{} - White = Color{255, 255, 255, 255} - Grey = Color{153, 153, 153, 255} - Black = Color{0, 0, 0, 255} - SkyBlue = Color{0, 153, 255, 255} - Blue = Color{0, 0, 255, 255} - Red = Color{255, 0, 0, 255} - Green = Color{0, 255, 0, 255} - Cyan = Color{0, 255, 255, 255} - Yellow = Color{255, 255, 0, 255} - Magenta = Color{255, 0, 255, 255} - Pink = Color{255, 153, 255, 255} + Invisible = Color{} + White = RGBA(255, 255, 255, 255) + Grey = RGBA(153, 153, 153, 255) + Black = RGBA(0, 0, 0, 255) + SkyBlue = RGBA(0, 153, 255, 255) + Blue = RGBA(0, 0, 255, 255) + DarkBlue = RGBA(0, 0, 153, 255) + Red = RGBA(255, 0, 0, 255) + DarkRed = RGBA(153, 0, 0, 255) + Green = RGBA(0, 255, 0, 255) + DarkGreen = RGBA(0, 153, 0, 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. diff --git a/render/sdl/canvas.go b/render/sdl/canvas.go index 28899e5..eee8d50 100644 --- a/render/sdl/canvas.go +++ b/render/sdl/canvas.go @@ -25,7 +25,7 @@ func (r *Renderer) DrawPoint(color render.Color, point render.Point) { // DrawLine draws a line between two points. func (r *Renderer) DrawLine(color render.Color, a, b render.Point) { 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) }