Collision Detection Fix + Doodad CLI Fixes
Fix collision detection when an actor's hitbox is offset from 0,0: * Actors having a hitbox that didn't begin at X,Y 0,0 used to experience clipping issues with level geometry: because the game tracks their Position (top left corner of their graphical sprite) and their target position wasn't being correctly offset by their hitbox offset. * To resolve the issue, an "ActorOffset" struct is added: you give it the original game's Actor (with its offset hitbox) and it will record the offset and give a mocked Actor for collision detection purposes: where the Position and Target can be offset and where its Hitbox claims to begin at 0,0 matching its offsetted Position. * The translation between your original Actor and Offset Actor is handled at the boundary of the CollidesWithGrid function, so the main algorithm didn't need to be messed with and the game itself doesn't need to care about the offset. Make some fixes to the doodad CLI tool: * Fix palette colors being duplicated/doubled when converting from an image. * The --palette flag in `doodad convert` now actually functions: so you can supply an initial palette.json with colors and attributes to e.g. mark which colors should be solid or fire and give them names. The palette.json doesn't need to be comprehensive: it will be extended with new distinct colors as needed during the conversion.
This commit is contained in:
parent
90414609a9
commit
1f00af5741
|
@ -140,6 +140,17 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
|||
images = append(images, img)
|
||||
}
|
||||
|
||||
// Initialize the palette from a JSON file?
|
||||
var palette *level.Palette
|
||||
if paletteFile := c.String("palette"); paletteFile != "" {
|
||||
log.Info("Loading initial palette from file: %s", paletteFile)
|
||||
if p, err := level.LoadPaletteFromFile(paletteFile); err != nil {
|
||||
return err
|
||||
} else {
|
||||
palette = p
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to translate image filenames into layer names.
|
||||
toLayerName := func(filename string) string {
|
||||
ext := filepath.Ext(filename)
|
||||
|
@ -161,7 +172,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
|||
log.Info("Converting first layer to drawing and getting the palette")
|
||||
var chunkSize = doodad.ChunkSize8()
|
||||
log.Info("Output is a Doodad file (%dx%d): %s", width, height, outputFile)
|
||||
palette, layer0 := imageToChunker(images[0], chroma, nil, chunkSize)
|
||||
palette, layer0 := imageToChunker(images[0], chroma, palette, chunkSize)
|
||||
doodad.Palette = palette
|
||||
doodad.Layers[0].Chunker = layer0
|
||||
doodad.Layers[0].Name = toLayerName(inputFiles[0])
|
||||
|
@ -196,7 +207,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
|||
lvl.Title = "Converted Level"
|
||||
}
|
||||
lvl.Author = native.DefaultAuthor
|
||||
palette, chunker := imageToChunker(images[0], chroma, nil, lvl.Chunker.Size)
|
||||
palette, chunker := imageToChunker(images[0], chroma, palette, lvl.Chunker.Size)
|
||||
lvl.Palette = palette
|
||||
lvl.Chunker = chunker
|
||||
|
||||
|
@ -291,6 +302,7 @@ func drawingToImage(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
|||
//
|
||||
// img: input image like a PNG
|
||||
// chroma: transparent color
|
||||
// palette: your palette so far (new distinct colors are added)
|
||||
func imageToChunker(img image.Image, chroma render.Color, palette *level.Palette, chunkSize uint8) (*level.Palette, *level.Chunker) {
|
||||
var (
|
||||
chunker = level.NewChunker(chunkSize)
|
||||
|
@ -344,7 +356,6 @@ func imageToChunker(img image.Image, chroma render.Color, palette *level.Palette
|
|||
log.Error("Could not add more colors to the palette: %s", err)
|
||||
panic(err.Error())
|
||||
}
|
||||
palette.Swatches = append(palette.Swatches, uniqueColor[hex])
|
||||
}
|
||||
}
|
||||
palette.Inflate()
|
||||
|
|
31
pkg/collision/actor_mock.go
Normal file
31
pkg/collision/actor_mock.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package collision
|
||||
|
||||
import "git.kirsle.net/go/render"
|
||||
|
||||
// MockActor implements the Actor interface for unit testing.
|
||||
type MockActor struct {
|
||||
P render.Point
|
||||
S render.Rect
|
||||
HB render.Rect
|
||||
G bool
|
||||
}
|
||||
|
||||
func (actor *MockActor) Position() render.Point {
|
||||
return actor.P
|
||||
}
|
||||
|
||||
func (actor *MockActor) Size() render.Rect {
|
||||
return actor.S
|
||||
}
|
||||
|
||||
func (actor *MockActor) Hitbox() render.Rect {
|
||||
return actor.HB
|
||||
}
|
||||
|
||||
func (actor *MockActor) Grounded() bool {
|
||||
return actor.G
|
||||
}
|
||||
|
||||
func (actor *MockActor) SetGrounded(v bool) {
|
||||
actor.G = v
|
||||
}
|
81
pkg/collision/actor_offset.go
Normal file
81
pkg/collision/actor_offset.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package collision
|
||||
|
||||
import "git.kirsle.net/go/render"
|
||||
|
||||
// ActorOffset helps normalize an actor's Position and Hitbox for collision detection.
|
||||
//
|
||||
// It allows for an actor to have a Hitbox which is offset from the 0,0 coordinate
|
||||
// in the top left corner. During gameplay, the actor's Position (top left corner of
|
||||
// its *sprite*) is what the game tracks for movement, but if the actor's declared hitbox
|
||||
// doesn't encompass the point 0,0 it used to lead to collision bugs.
|
||||
//
|
||||
// ActorOffset will take your original Actor, compute the offset between its Position
|
||||
// and its Hitbox, and return a simplified Actor that pretends the hitbox begins at 0,0.
|
||||
type ActorOffset struct {
|
||||
d Actor
|
||||
offset render.Point
|
||||
}
|
||||
|
||||
// NewActorOffset consumes the game's original Actor and returns one that simplifies
|
||||
// the Hitbox boundary.
|
||||
func NewActorOffset(d Actor) *ActorOffset {
|
||||
// Compute the offset from the actor's Position to its Hitbox.
|
||||
var (
|
||||
position = d.Position()
|
||||
hitbox = d.Hitbox()
|
||||
delta = render.Point{
|
||||
X: position.X + hitbox.X,
|
||||
Y: position.Y + hitbox.Y,
|
||||
}
|
||||
offset = render.Point{
|
||||
X: delta.X - position.X,
|
||||
Y: delta.Y - position.Y,
|
||||
}
|
||||
)
|
||||
return &ActorOffset{
|
||||
d: d,
|
||||
offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
// Offset returns the offset from the source actor's Position to their new one.
|
||||
func (ao *ActorOffset) Offset() render.Point {
|
||||
return ao.offset
|
||||
}
|
||||
|
||||
// Position will be the actor's original world position (of its sprite) plus the
|
||||
// hitbox offset coordinate.
|
||||
func (ao *ActorOffset) Position() render.Point {
|
||||
var P = ao.d.Position()
|
||||
return render.Point{
|
||||
X: P.X + ao.offset.X,
|
||||
Y: P.Y + ao.offset.Y,
|
||||
}
|
||||
}
|
||||
|
||||
// Size is the same as your original Actor.
|
||||
func (ao *ActorOffset) Size() render.Rect {
|
||||
return ao.d.Size()
|
||||
}
|
||||
|
||||
// Hitbox returns the actor's original Hitbox but where the X,Y are locked to 0,0.
|
||||
// The W,H of the hitbox is the same as original.
|
||||
func (ao *ActorOffset) Hitbox() render.Rect {
|
||||
var HB = ao.d.Hitbox()
|
||||
return render.Rect{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
H: HB.H,
|
||||
W: HB.W,
|
||||
}
|
||||
}
|
||||
|
||||
// Grounded returns your original actor's value.
|
||||
func (ao *ActorOffset) Grounded() bool {
|
||||
return ao.d.Grounded()
|
||||
}
|
||||
|
||||
// SetGrounded sets the grounded state of your original actor.
|
||||
func (ao *ActorOffset) SetGrounded(v bool) {
|
||||
ao.d.SetGrounded(v)
|
||||
}
|
56
pkg/collision/actor_offset_test.go
Normal file
56
pkg/collision/actor_offset_test.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package collision_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.kirsle.net/SketchyMaze/doodle/pkg/collision"
|
||||
"git.kirsle.net/go/render"
|
||||
)
|
||||
|
||||
func TestActorOffset(t *testing.T) {
|
||||
type testCase struct {
|
||||
Actor *collision.MockActor
|
||||
Offset render.Point
|
||||
ExpectPoint render.Point
|
||||
}
|
||||
|
||||
var tests = []testCase{
|
||||
// Simple case where the hitbox == the size.
|
||||
{
|
||||
Actor: &collision.MockActor{
|
||||
P: render.NewPoint(10, 10),
|
||||
S: render.NewRect(32, 32),
|
||||
HB: render.NewRect(32, 32),
|
||||
},
|
||||
ExpectPoint: render.NewPoint(10, 10),
|
||||
},
|
||||
|
||||
// Bottom heavy actor
|
||||
{
|
||||
Actor: &collision.MockActor{
|
||||
P: render.NewPoint(11, 22),
|
||||
S: render.NewRect(32, 64),
|
||||
HB: render.Rect{
|
||||
X: 0,
|
||||
Y: 32,
|
||||
W: 32,
|
||||
H: 32,
|
||||
},
|
||||
},
|
||||
ExpectPoint: render.NewPoint(11, 22+32),
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
offset := collision.NewActorOffset(test.Actor)
|
||||
|
||||
actualPoint := offset.Position()
|
||||
if actualPoint != test.ExpectPoint {
|
||||
t.Errorf("Test #%d: Position() expected to be %s but was %s",
|
||||
i,
|
||||
test.ExpectPoint,
|
||||
actualPoint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ func TestActorCollision(t *testing.T) {
|
|||
// Expected intersection rect would be
|
||||
// X,Y = 90,10
|
||||
// X2,Y2 = 100,99
|
||||
render.Rect{
|
||||
{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
W: 100,
|
||||
|
@ -24,7 +24,7 @@ func TestActorCollision(t *testing.T) {
|
|||
// Expected intersection rect would be
|
||||
// X,Y = 90,10
|
||||
// X2,Y2 = 100,99
|
||||
render.Rect{
|
||||
{
|
||||
X: 90,
|
||||
Y: 10,
|
||||
W: 100,
|
||||
|
@ -32,7 +32,7 @@ func TestActorCollision(t *testing.T) {
|
|||
},
|
||||
|
||||
// 2: no intersection
|
||||
render.Rect{
|
||||
{
|
||||
X: 200,
|
||||
Y: 200,
|
||||
W: 32,
|
||||
|
@ -43,7 +43,7 @@ func TestActorCollision(t *testing.T) {
|
|||
// Expected intersection rect would be
|
||||
// X,Y = 240,200
|
||||
// X2,Y2 = 264,231
|
||||
render.Rect{
|
||||
{
|
||||
X: 233,
|
||||
Y: 200,
|
||||
W: 32,
|
||||
|
@ -51,7 +51,7 @@ func TestActorCollision(t *testing.T) {
|
|||
},
|
||||
|
||||
// 4: intersects with 3
|
||||
render.Rect{
|
||||
{
|
||||
X: 240,
|
||||
Y: 200,
|
||||
W: 32,
|
||||
|
@ -59,19 +59,19 @@ func TestActorCollision(t *testing.T) {
|
|||
},
|
||||
|
||||
// 5: completely contains 6 and intersects 7.
|
||||
render.Rect{
|
||||
{
|
||||
X: 300,
|
||||
Y: 300,
|
||||
W: 1000,
|
||||
H: 600,
|
||||
},
|
||||
render.Rect{
|
||||
{
|
||||
X: 450,
|
||||
Y: 500,
|
||||
W: 42,
|
||||
H: 42,
|
||||
},
|
||||
render.Rect{
|
||||
{
|
||||
X: 1200,
|
||||
Y: 350,
|
||||
W: 512,
|
||||
|
|
|
@ -54,8 +54,48 @@ const (
|
|||
CollidesWithGrid checks if a Doodad collides with level geometry.
|
||||
|
||||
The `target` is the point the actor wants to move to on this tick.
|
||||
|
||||
This function handles translation for doodads having an offset hitbox which doesn't begin
|
||||
at the 0,0 coordinate on the X,Y axis.
|
||||
|
||||
For example:
|
||||
|
||||
- The caller of this function cares about where on screen to display the actor's sprite at
|
||||
(the X,Y position of the top left corner of their sprite).
|
||||
- The target point is where the actor is moving on this tick, which is also relative to their
|
||||
current world coordinate (top corner of their sprite), NOT their hitbox coordinate.
|
||||
|
||||
The original collision detection code worked well when the actor's hitbox began at 0,0 as it
|
||||
matched their world position. But when the hitbox is offset from the corner, collision detection
|
||||
glitches abounded.
|
||||
|
||||
This function will compute the physical hitbox of the doodad regarding the level geometry
|
||||
(simulating a simple doodad whose hitbox is a full 0,0,W,H) and translate the offset to and
|
||||
from.
|
||||
*/
|
||||
func CollidesWithGrid(d Actor, grid *level.Chunker, target render.Point) (*Collide, bool) {
|
||||
var (
|
||||
actor = NewActorOffset(d)
|
||||
offset = actor.Offset()
|
||||
newTarget = render.Point{
|
||||
X: target.X + offset.X,
|
||||
Y: target.Y + offset.Y,
|
||||
}
|
||||
)
|
||||
|
||||
collide, ok := BoxCollidesWithGrid(actor, grid, newTarget)
|
||||
|
||||
// Undo the offset for the MoveTo target.
|
||||
collide.MoveTo.X -= offset.X
|
||||
collide.MoveTo.Y -= offset.Y
|
||||
|
||||
return collide, ok
|
||||
}
|
||||
|
||||
/*
|
||||
BoxCollidesWithGrid handles the core logic for level collision checks.
|
||||
*/
|
||||
func BoxCollidesWithGrid(d Actor, grid *level.Chunker, target render.Point) (*Collide, bool) {
|
||||
var (
|
||||
P = d.Position()
|
||||
S = d.Size()
|
||||
|
|
|
@ -55,7 +55,7 @@ func TestCollisionFunctions(t *testing.T) {
|
|||
|
||||
// Test cases to check.
|
||||
tests := []testCase{
|
||||
testCase{
|
||||
{
|
||||
Start: render.NewPoint(0, 0),
|
||||
MoveTo: render.NewPoint(8, 8),
|
||||
ExpectCollision: false,
|
||||
|
@ -64,7 +64,7 @@ func TestCollisionFunctions(t *testing.T) {
|
|||
// Player is standing on the floor at X=100
|
||||
// with their feet at Y=500 and they move right
|
||||
// 10 pixels.
|
||||
testCase{
|
||||
{
|
||||
Start: render.NewPoint(
|
||||
100,
|
||||
500-playerSize.H,
|
||||
|
@ -80,7 +80,7 @@ func TestCollisionFunctions(t *testing.T) {
|
|||
},
|
||||
|
||||
// Player walks off the right edge of the platform.
|
||||
testCase{
|
||||
{
|
||||
// TODO: if the player is perfectly touching the floor,
|
||||
// this test fails and returns True for collision, so
|
||||
// I use 499-playerSize.H so they hover above the floor.
|
||||
|
@ -97,7 +97,7 @@ func TestCollisionFunctions(t *testing.T) {
|
|||
|
||||
// Player moves through the barrier in the middle and
|
||||
// is stopped in his tracks.
|
||||
testCase{
|
||||
{
|
||||
Start: render.NewPoint(
|
||||
490-playerSize.W, 500-playerSize.H,
|
||||
),
|
||||
|
@ -117,7 +117,7 @@ func TestCollisionFunctions(t *testing.T) {
|
|||
},
|
||||
|
||||
// Player moves up from below the platform and hits the ceiling.
|
||||
testCase{
|
||||
{
|
||||
Start: render.NewPoint(
|
||||
490-playerSize.W,
|
||||
550,
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package level
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.kirsle.net/go/render"
|
||||
)
|
||||
|
@ -78,6 +80,21 @@ func NewPalette() *Palette {
|
|||
}
|
||||
}
|
||||
|
||||
// LoadPaletteFromFile reads a list of Swatches from a palette.json file.
|
||||
func LoadPaletteFromFile(filename string) (*Palette, error) {
|
||||
var (
|
||||
pal = NewPalette()
|
||||
bin, err = os.ReadFile(filename)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(bin, &pal.Swatches)
|
||||
pal.update()
|
||||
return pal, err
|
||||
}
|
||||
|
||||
// Palette holds an index of colors used in a drawing.
|
||||
type Palette struct {
|
||||
Swatches []*Swatch `json:"swatches"`
|
||||
|
@ -134,6 +151,7 @@ func (p *Palette) AddSwatch(swatch *Swatch) error {
|
|||
return errors.New("only 256 colors are supported in a palette")
|
||||
}
|
||||
|
||||
swatch.index = index
|
||||
p.Swatches = append(p.Swatches, swatch)
|
||||
p.byName[swatch.Name] = index
|
||||
|
||||
|
|
|
@ -39,13 +39,23 @@ func NewSparseSwatch(paletteIndex int) *Swatch {
|
|||
}
|
||||
|
||||
func (s Swatch) String() string {
|
||||
var parts = []string{
|
||||
fmt.Sprintf("#%d", s.Index()),
|
||||
}
|
||||
|
||||
if s.Name != "" {
|
||||
parts = append(parts, fmt.Sprintf("'%s'", s.Name))
|
||||
}
|
||||
|
||||
if s.isSparse {
|
||||
return fmt.Sprintf("Swatch<sparse:%d>", s.paletteIndex)
|
||||
parts = append(parts, "sparse")
|
||||
} else {
|
||||
parts = append(parts, s.Color.ToHex())
|
||||
}
|
||||
if s.Name == "" {
|
||||
return s.Color.String()
|
||||
}
|
||||
return s.Name
|
||||
|
||||
parts = append(parts, s.Attributes())
|
||||
|
||||
return fmt.Sprintf("Swatch<%s>", strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// Attributes returns a comma-separated list of attributes as a string on
|
||||
|
|
Loading…
Reference in New Issue
Block a user