diff --git a/hitbox-tool/README.md b/hitbox-tool/README.md new file mode 100644 index 0000000..51a2d43 --- /dev/null +++ b/hitbox-tool/README.md @@ -0,0 +1,66 @@ +# hitbox-tool (`hitbox`) + +Scans **sprite sheet PNGs** and writes per-frame **collision boxes as +JSON**, computed from the alpha channel. Closes the loop with +[`pixel-sprite-maker`](../pixel-sprite-maker/): render a sheet with +`spritec`, scan it with `hitbox`, and your game gets both graphics and +hitboxes without a human drawing rectangles. Go, zero dependencies, +single static binary. + +## Build + +```bash +# Arch/Garuda: sudo pacman -S go +cd hitbox-tool +go build -o build/hitbox . # or the VS Code task "build hitbox-tool" +go test ./... +``` + +## Usage + +```bash +# spritec-named sheets need no flags — layout is parsed from the name, +# and integer upscales are auto-detected (a _8x8_4x1 sheet that is +# 256x64 px was rendered at --scale 8, so cells are 64x64): +hitbox scan walk_8x8_4x1.png -o walk.hitbox.json + +hitbox scan boss.png --cell 32x32 -o boss.json # explicit frame size +hitbox scan portrait.png # whole image = one frame, JSON to stdout + +hitbox show walk_8x8_4x1.png --frame 2 # verify visually in the terminal + +# tuning: +--threshold 128 # ignore faint pixels (alpha < 128) +--shrink 1 # tighter, more forgiving hitboxes (n px per side) +--pad 2 # generous hitboxes (e.g. pickups) +``` + +## Output + +```json +{ + "image": "walk_8x8_4x1.png", + "cellW": 8, "cellH": 8, "cols": 4, "rows": 1, + "alphaThreshold": 1, + "frames": [ + { "index": 0, "col": 0, "row": 0, "empty": false, + "box": { "x": 1, "y": 0, "w": 5, "h": 8 } }, + { "index": 1, "col": 1, "row": 0, "empty": true, "box": null } + ] +} +``` + +- Boxes are **relative to each frame's top-left corner**. +- Frame order is row-major (`index = row * cols + col`), matching + spritec sheets. +- Cells with no solid pixels get `"empty": true`. + +In game code: + +``` +hit = px >= frameX + box.x && px < frameX + box.x + box.w + && py >= frameY + box.y && py < frameY + box.y + box.h +``` + +`hitbox show` draws each frame with `#` for solid pixels and `+` for the +box outline, so an agent can verify the result without an image viewer. diff --git a/hitbox-tool/examples/walk_8x8_4x1.hitbox.json b/hitbox-tool/examples/walk_8x8_4x1.hitbox.json new file mode 100644 index 0000000..5155614 --- /dev/null +++ b/hitbox-tool/examples/walk_8x8_4x1.hitbox.json @@ -0,0 +1,58 @@ +{ + "image": "examples/walk_8x8_4x1.png", + "cellW": 64, + "cellH": 64, + "cols": 4, + "rows": 1, + "alphaThreshold": 1, + "frames": [ + { + "index": 0, + "col": 0, + "row": 0, + "empty": false, + "box": { + "x": 8, + "y": 0, + "w": 40, + "h": 64 + } + }, + { + "index": 1, + "col": 1, + "row": 0, + "empty": false, + "box": { + "x": 8, + "y": 0, + "w": 40, + "h": 64 + } + }, + { + "index": 2, + "col": 2, + "row": 0, + "empty": false, + "box": { + "x": 8, + "y": 0, + "w": 40, + "h": 64 + } + }, + { + "index": 3, + "col": 3, + "row": 0, + "empty": false, + "box": { + "x": 8, + "y": 0, + "w": 40, + "h": 64 + } + } + ] +} diff --git a/hitbox-tool/examples/walk_8x8_4x1.png b/hitbox-tool/examples/walk_8x8_4x1.png new file mode 100644 index 0000000..04c7bc8 Binary files /dev/null and b/hitbox-tool/examples/walk_8x8_4x1.png differ diff --git a/hitbox-tool/go.mod b/hitbox-tool/go.mod new file mode 100644 index 0000000..f8f4b3c --- /dev/null +++ b/hitbox-tool/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool + +go 1.24 diff --git a/hitbox-tool/hitbox/hitbox.go b/hitbox-tool/hitbox/hitbox.go new file mode 100644 index 0000000..28d5dff --- /dev/null +++ b/hitbox-tool/hitbox/hitbox.go @@ -0,0 +1,230 @@ +// Package hitbox computes per-frame collision boxes from sprite sheet +// images by scanning the alpha channel. +package hitbox + +import ( + "encoding/json" + "fmt" + "image" + "image/png" + "io" + "os" + "path/filepath" + "regexp" + "strconv" +) + +// Box is a rectangle relative to its frame's top-left corner. +type Box struct { + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` +} + +// Frame is the scan result for one sheet cell. +type Frame struct { + Index int `json:"index"` + Col int `json:"col"` + Row int `json:"row"` + Empty bool `json:"empty"` + Box *Box `json:"box"` // nil when Empty +} + +// Sheet is the full scan result; the JSON deliverable. +type Sheet struct { + Image string `json:"image"` + CellW int `json:"cellW"` + CellH int `json:"cellH"` + Cols int `json:"cols"` + Rows int `json:"rows"` + Threshold int `json:"alphaThreshold"` + Frames []Frame `json:"frames"` +} + +// layoutRe matches the spritec sheet naming convention: +// _x_x. +var layoutRe = regexp.MustCompile(`_(\d+)x(\d+)_(\d+)x(\d+)\.[A-Za-z]+$`) + +// LayoutFromName extracts cell size and grid from a spritec-style file +// name. ok is false when the name doesn't follow the convention. +func LayoutFromName(path string) (cellW, cellH, cols, rows int, ok bool) { + m := layoutRe.FindStringSubmatch(filepath.Base(path)) + if m == nil { + return 0, 0, 0, 0, false + } + cellW, _ = strconv.Atoi(m[1]) + cellH, _ = strconv.Atoi(m[2]) + cols, _ = strconv.Atoi(m[3]) + rows, _ = strconv.Atoi(m[4]) + return cellW, cellH, cols, rows, true +} + +// InferScale detects integer-upscaled sheets: when the image is exactly +// s times bigger than the name-declared layout (both axes, s >= 1), the +// real cell size is cell*s. Returns 0 when the layout doesn't fit. +func InferScale(imgW, imgH, cellW, cellH, cols, rows int) int { + baseW, baseH := cellW*cols, cellH*rows + if baseW <= 0 || baseH <= 0 || imgW%baseW != 0 || imgH%baseH != 0 { + return 0 + } + s := imgW / baseW + if s < 1 || imgH/baseH != s { + return 0 + } + return s +} + +// LoadPNG reads a PNG image from disk. +func LoadPNG(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, err := png.Decode(f) + if err != nil { + return nil, fmt.Errorf("%s: %w (only PNG is supported — sheets need an alpha channel)", path, err) + } + return img, nil +} + +// Scan computes a tight box per frame: the smallest rectangle covering +// every pixel with alpha >= threshold. shrink/pad (in pixels) contract or +// expand each box afterwards, clamped to the cell. +func Scan(img image.Image, name string, cellW, cellH, threshold, shrink, pad int) (*Sheet, error) { + b := img.Bounds() + if cellW <= 0 || cellH <= 0 { + cellW, cellH = b.Dx(), b.Dy() // whole image = one frame + } + if b.Dx()%cellW != 0 || b.Dy()%cellH != 0 { + return nil, fmt.Errorf("image is %dx%d which is not divisible by cell %dx%d", + b.Dx(), b.Dy(), cellW, cellH) + } + if threshold < 1 || threshold > 255 { + return nil, fmt.Errorf("alpha threshold %d out of range 1-255", threshold) + } + cols, rows := b.Dx()/cellW, b.Dy()/cellH + sheet := &Sheet{ + Image: name, CellW: cellW, CellH: cellH, + Cols: cols, Rows: rows, Threshold: threshold, + } + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + fr := Frame{Index: row*cols + col, Col: col, Row: row} + minX, minY := cellW, cellH + maxX, maxY := -1, -1 + for y := 0; y < cellH; y++ { + for x := 0; x < cellW; x++ { + _, _, _, a := img.At(b.Min.X+col*cellW+x, b.Min.Y+row*cellH+y).RGBA() + if int(a>>8) >= threshold { + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x > maxX { + maxX = x + } + if y > maxY { + maxY = y + } + } + } + } + if maxX < 0 { + fr.Empty = true + } else { + box := Box{X: minX, Y: minY, W: maxX - minX + 1, H: maxY - minY + 1} + box = adjust(box, shrink-pad, cellW, cellH) + fr.Box = &box + } + sheet.Frames = append(sheet.Frames, fr) + } + } + return sheet, nil +} + +// adjust contracts the box by delta px on every side (negative delta +// expands), clamped to the cell and to a minimum size of 1x1. +func adjust(b Box, delta, cellW, cellH int) Box { + b.X += delta + b.Y += delta + b.W -= 2 * delta + b.H -= 2 * delta + if b.X < 0 { + b.W += b.X + b.X = 0 + } + if b.Y < 0 { + b.H += b.Y + b.Y = 0 + } + if b.X+b.W > cellW { + b.W = cellW - b.X + } + if b.Y+b.H > cellH { + b.H = cellH - b.Y + } + if b.W < 1 { + b.W = 1 + if b.X > cellW-1 { + b.X = cellW - 1 + } + } + if b.H < 1 { + b.H = 1 + if b.Y > cellH-1 { + b.Y = cellH - 1 + } + } + return b +} + +// WriteJSON encodes the sheet as indented JSON. +func (s *Sheet) WriteJSON(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(s) +} + +// Ascii draws one frame with '#' for solid pixels and '+' for the box +// outline, so results can be verified in a terminal. +func Ascii(img image.Image, s *Sheet, index, threshold int) string { + if index < 0 || index >= len(s.Frames) { + return "(no such frame)\n" + } + fr := s.Frames[index] + b := img.Bounds() + out := make([]rune, 0, (s.CellW+1)*s.CellH) + onBoxEdge := func(x, y int) bool { + if fr.Box == nil { + return false + } + bx := fr.Box + inX := x >= bx.X && x < bx.X+bx.W + inY := y >= bx.Y && y < bx.Y+bx.H + edgeX := x == bx.X || x == bx.X+bx.W-1 + edgeY := y == bx.Y || y == bx.Y+bx.H-1 + return (inX && inY) && (edgeX || edgeY) + } + for y := 0; y < s.CellH; y++ { + for x := 0; x < s.CellW; x++ { + _, _, _, a := img.At(b.Min.X+fr.Col*s.CellW+x, b.Min.Y+fr.Row*s.CellH+y).RGBA() + solid := int(a>>8) >= threshold + switch { + case solid && onBoxEdge(x, y): + out = append(out, '#') + case solid: + out = append(out, '#') + case onBoxEdge(x, y): + out = append(out, '+') + default: + out = append(out, '.') + } + } + out = append(out, '\n') + } + return string(out) +} diff --git a/hitbox-tool/hitbox/hitbox_test.go b/hitbox-tool/hitbox/hitbox_test.go new file mode 100644 index 0000000..84b9c38 --- /dev/null +++ b/hitbox-tool/hitbox/hitbox_test.go @@ -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) + } +} diff --git a/hitbox-tool/main.go b/hitbox-tool/main.go new file mode 100644 index 0000000..390e3f5 --- /dev/null +++ b/hitbox-tool/main.go @@ -0,0 +1,204 @@ +// hitbox scans sprite sheet PNGs and writes per-frame collision boxes +// as JSON, using the alpha channel to find each frame's solid pixels. +package main + +import ( + "flag" + "fmt" + "os" + "strconv" + "strings" + + "gitea.brasse-pc.eu/brasse/agent-tools/hitbox-tool/hitbox" +) + +var version = "dev" + +const usage = `hitbox - collision box annotator for sprite sheets + +Usage: + hitbox scan [flags] compute per-frame boxes -> JSON + hitbox show [flags] draw frames + boxes in the terminal + hitbox version + +Scan flags: + --cell frame size, e.g. 8x8. Default: parsed from the + spritec naming convention _x_x.png; + if neither is given the whole image is one frame. + --threshold alpha 1-255 that counts as solid (default 1) + --shrink contract every box by n px per side (forgiving hits) + --pad expand every box by n px per side + -o write JSON here (default: stdout) + +Show flags: --cell, --threshold, plus + --frame only this frame index (default: all) + +Boxes are relative to each frame's top-left corner. Frame indices are +row-major (index = row * cols + col), matching spritec sheets. +In game code: hit if (px,py) inside (frameX + box.x, frameY + box.y, +box.w, box.h). +` + +func main() { + if len(os.Args) < 2 { + fmt.Print(usage) + os.Exit(2) + } + switch os.Args[1] { + case "scan": + cmdScan(os.Args[2:]) + case "show": + cmdShow(os.Args[2:]) + case "version", "--version", "-v": + fmt.Println("hitbox", version) + case "help", "--help", "-h": + fmt.Print(usage) + default: + die("unknown command %q — run 'hitbox help'", os.Args[1]) + } +} + +// parseInterspersed lets flags appear before or after positional args. +func parseInterspersed(fs *flag.FlagSet, args []string) { + var flags, pos []string + for i := 0; i < len(args); i++ { + a := args[i] + if len(a) > 1 && a[0] == '-' { + flags = append(flags, a) + name := strings.TrimLeft(a, "-") + if !strings.Contains(name, "=") { + f := fs.Lookup(name) + isBool := false + if f != nil { + if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() { + isBool = true + } + } + if !isBool && i+1 < len(args) { + i++ + flags = append(flags, args[i]) + } + } + } else { + pos = append(pos, a) + } + } + fs.Parse(append(flags, pos...)) +} + +// parseCell resolves the frame size from --cell, or from the spritec +// naming convention (auto-detecting integer upscales: a sheet named +// _8x8_4x1 that is 256x64 px was rendered at scale 8, so cells are 64x64). +func parseCell(spec, path string, imgW, imgH int) (int, int, error) { + if spec != "" { + parts := strings.SplitN(strings.ToLower(spec), "x", 2) + if len(parts) == 2 { + w, err1 := strconv.Atoi(parts[0]) + h, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil && w > 0 && h > 0 { + return w, h, nil + } + } + return 0, 0, fmt.Errorf("--cell %q must look like 8x8", spec) + } + if w, h, cols, rows, ok := hitbox.LayoutFromName(path); ok { + if s := hitbox.InferScale(imgW, imgH, w, h, cols, rows); s > 1 { + fmt.Fprintf(os.Stderr, "hitbox: image is %dx the named layout — using %dx%d cells\n", s, w*s, h*s) + return w * s, h * s, nil + } + return w, h, nil + } + return 0, 0, nil // whole image = one frame +} + +func cmdScan(args []string) { + fs := flag.NewFlagSet("scan", flag.ExitOnError) + cell := fs.String("cell", "", "frame size WxH") + threshold := fs.Int("threshold", 1, "solid alpha 1-255") + shrink := fs.Int("shrink", 0, "contract boxes n px per side") + pad := fs.Int("pad", 0, "expand boxes n px per side") + out := fs.String("o", "", "output JSON path (default stdout)") + parseInterspersed(fs, args) + if fs.NArg() != 1 { + die("scan takes exactly one PNG file") + } + path := fs.Arg(0) + img, err := hitbox.LoadPNG(path) + if err != nil { + die("%v", err) + } + b := img.Bounds() + cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy()) + if err != nil { + die("%v", err) + } + sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, *shrink, *pad) + if err != nil { + die("%v", err) + } + if *out == "" { + if err := sheet.WriteJSON(os.Stdout); err != nil { + die("%v", err) + } + return + } + f, err := os.Create(*out) + if err != nil { + die("%v", err) + } + defer f.Close() + if err := sheet.WriteJSON(f); err != nil { + die("%v", err) + } + solid := 0 + for _, fr := range sheet.Frames { + if !fr.Empty { + solid++ + } + } + fmt.Printf("%s (%d frames of %dx%d, %d with pixels)\n", + *out, len(sheet.Frames), sheet.CellW, sheet.CellH, solid) +} + +func cmdShow(args []string) { + fs := flag.NewFlagSet("show", flag.ExitOnError) + cell := fs.String("cell", "", "frame size WxH") + threshold := fs.Int("threshold", 1, "solid alpha 1-255") + frame := fs.Int("frame", -1, "frame index (default: all)") + parseInterspersed(fs, args) + if fs.NArg() != 1 { + die("show takes exactly one PNG file") + } + path := fs.Arg(0) + img, err := hitbox.LoadPNG(path) + if err != nil { + die("%v", err) + } + b := img.Bounds() + cw, ch, err := parseCell(*cell, path, b.Dx(), b.Dy()) + if err != nil { + die("%v", err) + } + sheet, err := hitbox.Scan(img, path, cw, ch, *threshold, 0, 0) + if err != nil { + die("%v", err) + } + for _, fr := range sheet.Frames { + if *frame >= 0 && fr.Index != *frame { + continue + } + if fr.Empty { + fmt.Printf("frame %d (col %d, row %d): empty\n\n", fr.Index, fr.Col, fr.Row) + continue + } + fmt.Printf("frame %d (col %d, row %d): box x=%d y=%d w=%d h=%d\n", + fr.Index, fr.Col, fr.Row, fr.Box.X, fr.Box.Y, fr.Box.W, fr.Box.H) + fmt.Print(hitbox.Ascii(img, sheet, fr.Index, *threshold)) + fmt.Println() + } +} + +func die(format string, a ...any) { + fmt.Fprintf(os.Stderr, "hitbox: "+format+"\n", a...) + os.Exit(1) +}