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