hitbox-tool: per-frame collision boxes from sprite sheet alpha -> JSON
- parses the spritec _WxH_CxR naming convention incl. auto-detection of integer-upscaled sheets (256x64 named 8x8_4x1 -> 64x64 cells) - tight alpha bbox per frame with threshold/shrink/pad tuning, row-major indices, empty-frame flags; boxes relative to frame origin - show command draws frames + box outline in the terminal for verification - go tests: scanning, threshold, shrink/pad clamping, name parsing, scale inference, JSON roundtrip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
This commit is contained in:
137
hitbox-tool/hitbox/hitbox_test.go
Normal file
137
hitbox-tool/hitbox/hitbox_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package hitbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sheet4 builds a 2x1 sheet of 4x4 cells: frame 0 has a 2x2 blob at
|
||||
// (1,1); frame 1 is empty.
|
||||
func sheet4() image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 8, 4))
|
||||
for y := 1; y <= 2; y++ {
|
||||
for x := 1; x <= 2; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestScanTightBox(t *testing.T) {
|
||||
s, err := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Cols != 2 || s.Rows != 1 || len(s.Frames) != 2 {
|
||||
t.Fatalf("layout %dx%d frames=%d", s.Cols, s.Rows, len(s.Frames))
|
||||
}
|
||||
f0 := s.Frames[0]
|
||||
if f0.Empty || f0.Box == nil {
|
||||
t.Fatal("frame 0 should have a box")
|
||||
}
|
||||
if *f0.Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
|
||||
t.Errorf("frame 0 box = %+v", *f0.Box)
|
||||
}
|
||||
f1 := s.Frames[1]
|
||||
if !f1.Empty || f1.Box != nil {
|
||||
t.Errorf("frame 1 should be empty, got %+v", f1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanWholeImageDefault(t *testing.T) {
|
||||
s, err := Scan(sheet4(), "x.png", 0, 0, 1, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Frames) != 1 || s.CellW != 8 || s.CellH != 4 {
|
||||
t.Errorf("whole-image scan: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanErrors(t *testing.T) {
|
||||
if _, err := Scan(sheet4(), "x.png", 3, 4, 1, 0, 0); err == nil {
|
||||
t.Error("non-divisible cell size should error")
|
||||
}
|
||||
if _, err := Scan(sheet4(), "x.png", 4, 4, 0, 0, 0); err == nil {
|
||||
t.Error("threshold 0 should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShrinkAndPad(t *testing.T) {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
s, _ := Scan(img, "x.png", 4, 4, 1, 1, 0) // shrink 1
|
||||
if *s.Frames[0].Box != (Box{X: 1, Y: 1, W: 2, H: 2}) {
|
||||
t.Errorf("shrunk box = %+v", *s.Frames[0].Box)
|
||||
}
|
||||
s, _ = Scan(img, "x.png", 4, 4, 1, 0, 3) // pad clamps to cell
|
||||
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 4, H: 4}) {
|
||||
t.Errorf("padded box = %+v", *s.Frames[0].Box)
|
||||
}
|
||||
s, _ = Scan(img, "x.png", 4, 4, 1, 10, 0) // over-shrink -> min 1x1
|
||||
if s.Frames[0].Box.W < 1 || s.Frames[0].Box.H < 1 {
|
||||
t.Errorf("over-shrunk box = %+v", *s.Frames[0].Box)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreshold(t *testing.T) {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 2, 1))
|
||||
img.SetNRGBA(0, 0, color.NRGBA{0, 0, 0, 100})
|
||||
img.SetNRGBA(1, 0, color.NRGBA{0, 0, 0, 200})
|
||||
s, _ := Scan(img, "x.png", 2, 1, 150, 0, 0)
|
||||
if *s.Frames[0].Box != (Box{X: 1, Y: 0, W: 1, H: 1}) {
|
||||
t.Errorf("threshold box = %+v", *s.Frames[0].Box)
|
||||
}
|
||||
s, _ = Scan(img, "x.png", 2, 1, 50, 0, 0)
|
||||
if *s.Frames[0].Box != (Box{X: 0, Y: 0, W: 2, H: 1}) {
|
||||
t.Errorf("low-threshold box = %+v", *s.Frames[0].Box)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutFromName(t *testing.T) {
|
||||
w, h, c, r, ok := LayoutFromName("/tmp/walk_8x8_4x1.png")
|
||||
if !ok || w != 8 || h != 8 || c != 4 || r != 1 {
|
||||
t.Errorf("parsed %d %d %d %d ok=%v", w, h, c, r, ok)
|
||||
}
|
||||
if _, _, _, _, ok := LayoutFromName("plain.png"); ok {
|
||||
t.Error("plain.png should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferScale(t *testing.T) {
|
||||
// walk_8x8_4x1.png rendered at scale 8 -> 256x64
|
||||
if s := InferScale(256, 64, 8, 8, 4, 1); s != 8 {
|
||||
t.Errorf("scale = %d, want 8", s)
|
||||
}
|
||||
if s := InferScale(32, 8, 8, 8, 4, 1); s != 1 {
|
||||
t.Errorf("unscaled = %d, want 1", s)
|
||||
}
|
||||
if s := InferScale(250, 64, 8, 8, 4, 1); s != 0 {
|
||||
t.Errorf("non-divisible = %d, want 0", s)
|
||||
}
|
||||
if s := InferScale(256, 32, 8, 8, 4, 1); s != 0 {
|
||||
t.Errorf("axis mismatch = %d, want 0", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONShape(t *testing.T) {
|
||||
s, _ := Scan(sheet4(), "test.png", 4, 4, 1, 0, 0)
|
||||
var buf bytes.Buffer
|
||||
if err := s.WriteJSON(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back Sheet
|
||||
if err := json.Unmarshal(buf.Bytes(), &back); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if back.Frames[0].Box.W != 2 {
|
||||
t.Errorf("json roundtrip box = %+v", back.Frames[0].Box)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user