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:
Noah 2024-05-27 15:14:00 -07:00
parent 90414609a9
commit 1f00af5741
9 changed files with 268 additions and 21 deletions

View File

@ -140,6 +140,17 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
images = append(images, img) 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. // Helper function to translate image filenames into layer names.
toLayerName := func(filename string) string { toLayerName := func(filename string) string {
ext := filepath.Ext(filename) 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") log.Info("Converting first layer to drawing and getting the palette")
var chunkSize = doodad.ChunkSize8() var chunkSize = doodad.ChunkSize8()
log.Info("Output is a Doodad file (%dx%d): %s", width, height, outputFile) 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.Palette = palette
doodad.Layers[0].Chunker = layer0 doodad.Layers[0].Chunker = layer0
doodad.Layers[0].Name = toLayerName(inputFiles[0]) 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.Title = "Converted Level"
} }
lvl.Author = native.DefaultAuthor 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.Palette = palette
lvl.Chunker = chunker lvl.Chunker = chunker
@ -291,6 +302,7 @@ func drawingToImage(c *cli.Context, chroma render.Color, inputFiles []string, ou
// //
// img: input image like a PNG // img: input image like a PNG
// chroma: transparent color // 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) { func imageToChunker(img image.Image, chroma render.Color, palette *level.Palette, chunkSize uint8) (*level.Palette, *level.Chunker) {
var ( var (
chunker = level.NewChunker(chunkSize) 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) log.Error("Could not add more colors to the palette: %s", err)
panic(err.Error()) panic(err.Error())
} }
palette.Swatches = append(palette.Swatches, uniqueColor[hex])
} }
} }
palette.Inflate() palette.Inflate()

View 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
}

View 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)
}

View 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,
)
}
}
}

View File

@ -13,7 +13,7 @@ func TestActorCollision(t *testing.T) {
// Expected intersection rect would be // Expected intersection rect would be
// X,Y = 90,10 // X,Y = 90,10
// X2,Y2 = 100,99 // X2,Y2 = 100,99
render.Rect{ {
X: 0, X: 0,
Y: 0, Y: 0,
W: 100, W: 100,
@ -24,7 +24,7 @@ func TestActorCollision(t *testing.T) {
// Expected intersection rect would be // Expected intersection rect would be
// X,Y = 90,10 // X,Y = 90,10
// X2,Y2 = 100,99 // X2,Y2 = 100,99
render.Rect{ {
X: 90, X: 90,
Y: 10, Y: 10,
W: 100, W: 100,
@ -32,7 +32,7 @@ func TestActorCollision(t *testing.T) {
}, },
// 2: no intersection // 2: no intersection
render.Rect{ {
X: 200, X: 200,
Y: 200, Y: 200,
W: 32, W: 32,
@ -43,7 +43,7 @@ func TestActorCollision(t *testing.T) {
// Expected intersection rect would be // Expected intersection rect would be
// X,Y = 240,200 // X,Y = 240,200
// X2,Y2 = 264,231 // X2,Y2 = 264,231
render.Rect{ {
X: 233, X: 233,
Y: 200, Y: 200,
W: 32, W: 32,
@ -51,7 +51,7 @@ func TestActorCollision(t *testing.T) {
}, },
// 4: intersects with 3 // 4: intersects with 3
render.Rect{ {
X: 240, X: 240,
Y: 200, Y: 200,
W: 32, W: 32,
@ -59,19 +59,19 @@ func TestActorCollision(t *testing.T) {
}, },
// 5: completely contains 6 and intersects 7. // 5: completely contains 6 and intersects 7.
render.Rect{ {
X: 300, X: 300,
Y: 300, Y: 300,
W: 1000, W: 1000,
H: 600, H: 600,
}, },
render.Rect{ {
X: 450, X: 450,
Y: 500, Y: 500,
W: 42, W: 42,
H: 42, H: 42,
}, },
render.Rect{ {
X: 1200, X: 1200,
Y: 350, Y: 350,
W: 512, W: 512,

View File

@ -54,8 +54,48 @@ const (
CollidesWithGrid checks if a Doodad collides with level geometry. CollidesWithGrid checks if a Doodad collides with level geometry.
The `target` is the point the actor wants to move to on this tick. 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) { 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 ( var (
P = d.Position() P = d.Position()
S = d.Size() S = d.Size()

View File

@ -55,7 +55,7 @@ func TestCollisionFunctions(t *testing.T) {
// Test cases to check. // Test cases to check.
tests := []testCase{ tests := []testCase{
testCase{ {
Start: render.NewPoint(0, 0), Start: render.NewPoint(0, 0),
MoveTo: render.NewPoint(8, 8), MoveTo: render.NewPoint(8, 8),
ExpectCollision: false, ExpectCollision: false,
@ -64,7 +64,7 @@ func TestCollisionFunctions(t *testing.T) {
// Player is standing on the floor at X=100 // Player is standing on the floor at X=100
// with their feet at Y=500 and they move right // with their feet at Y=500 and they move right
// 10 pixels. // 10 pixels.
testCase{ {
Start: render.NewPoint( Start: render.NewPoint(
100, 100,
500-playerSize.H, 500-playerSize.H,
@ -80,7 +80,7 @@ func TestCollisionFunctions(t *testing.T) {
}, },
// Player walks off the right edge of the platform. // Player walks off the right edge of the platform.
testCase{ {
// TODO: if the player is perfectly touching the floor, // TODO: if the player is perfectly touching the floor,
// this test fails and returns True for collision, so // this test fails and returns True for collision, so
// I use 499-playerSize.H so they hover above the floor. // 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 // Player moves through the barrier in the middle and
// is stopped in his tracks. // is stopped in his tracks.
testCase{ {
Start: render.NewPoint( Start: render.NewPoint(
490-playerSize.W, 500-playerSize.H, 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. // Player moves up from below the platform and hits the ceiling.
testCase{ {
Start: render.NewPoint( Start: render.NewPoint(
490-playerSize.W, 490-playerSize.W,
550, 550,

View File

@ -1,8 +1,10 @@
package level package level
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"git.kirsle.net/go/render" "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. // Palette holds an index of colors used in a drawing.
type Palette struct { type Palette struct {
Swatches []*Swatch `json:"swatches"` 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") return errors.New("only 256 colors are supported in a palette")
} }
swatch.index = index
p.Swatches = append(p.Swatches, swatch) p.Swatches = append(p.Swatches, swatch)
p.byName[swatch.Name] = index p.byName[swatch.Name] = index

View File

@ -39,13 +39,23 @@ func NewSparseSwatch(paletteIndex int) *Swatch {
} }
func (s Swatch) String() string { 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 { 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() parts = append(parts, s.Attributes())
}
return s.Name return fmt.Sprintf("Swatch<%s>", strings.Join(parts, " "))
} }
// Attributes returns a comma-separated list of attributes as a string on // Attributes returns a comma-separated list of attributes as a string on