From 1f00af5741c8f211d81b8053ad067054c4c38833 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Mon, 27 May 2024 15:14:00 -0700 Subject: [PATCH] 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. --- cmd/doodad/commands/convert.go | 17 +++++-- pkg/collision/actor_mock.go | 31 ++++++++++++ pkg/collision/actor_offset.go | 81 ++++++++++++++++++++++++++++++ pkg/collision/actor_offset_test.go | 56 +++++++++++++++++++++ pkg/collision/actors_test.go | 16 +++--- pkg/collision/collide_level.go | 40 +++++++++++++++ pkg/collision/level_test.go | 10 ++-- pkg/level/palette.go | 18 +++++++ pkg/level/swatch.go | 20 ++++++-- 9 files changed, 268 insertions(+), 21 deletions(-) create mode 100644 pkg/collision/actor_mock.go create mode 100644 pkg/collision/actor_offset.go create mode 100644 pkg/collision/actor_offset_test.go diff --git a/cmd/doodad/commands/convert.go b/cmd/doodad/commands/convert.go index 75873c4..bb077aa 100644 --- a/cmd/doodad/commands/convert.go +++ b/cmd/doodad/commands/convert.go @@ -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() diff --git a/pkg/collision/actor_mock.go b/pkg/collision/actor_mock.go new file mode 100644 index 0000000..83ca346 --- /dev/null +++ b/pkg/collision/actor_mock.go @@ -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 +} diff --git a/pkg/collision/actor_offset.go b/pkg/collision/actor_offset.go new file mode 100644 index 0000000..33abdd0 --- /dev/null +++ b/pkg/collision/actor_offset.go @@ -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) +} diff --git a/pkg/collision/actor_offset_test.go b/pkg/collision/actor_offset_test.go new file mode 100644 index 0000000..3000c3b --- /dev/null +++ b/pkg/collision/actor_offset_test.go @@ -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, + ) + } + } +} diff --git a/pkg/collision/actors_test.go b/pkg/collision/actors_test.go index b7473ce..60cac38 100644 --- a/pkg/collision/actors_test.go +++ b/pkg/collision/actors_test.go @@ -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, diff --git a/pkg/collision/collide_level.go b/pkg/collision/collide_level.go index ed04f74..c3e201d 100644 --- a/pkg/collision/collide_level.go +++ b/pkg/collision/collide_level.go @@ -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() diff --git a/pkg/collision/level_test.go b/pkg/collision/level_test.go index 792f360..ee0db69 100644 --- a/pkg/collision/level_test.go +++ b/pkg/collision/level_test.go @@ -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, diff --git a/pkg/level/palette.go b/pkg/level/palette.go index 1cec028..41938e7 100644 --- a/pkg/level/palette.go +++ b/pkg/level/palette.go @@ -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 diff --git a/pkg/level/swatch.go b/pkg/level/swatch.go index be346ac..c340513 100644 --- a/pkg/level/swatch.go +++ b/pkg/level/swatch.go @@ -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", 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