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:
204
hitbox-tool/main.go
Normal file
204
hitbox-tool/main.go
Normal file
@@ -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 <sheet.png> [flags] compute per-frame boxes -> JSON
|
||||
hitbox show <sheet.png> [flags] draw frames + boxes in the terminal
|
||||
hitbox version
|
||||
|
||||
Scan flags:
|
||||
--cell <WxH> frame size, e.g. 8x8. Default: parsed from the
|
||||
spritec naming convention <name>_<W>x<H>_<C>x<R>.png;
|
||||
if neither is given the whole image is one frame.
|
||||
--threshold <n> alpha 1-255 that counts as solid (default 1)
|
||||
--shrink <n> contract every box by n px per side (forgiving hits)
|
||||
--pad <n> expand every box by n px per side
|
||||
-o <path> write JSON here (default: stdout)
|
||||
|
||||
Show flags: --cell, --threshold, plus
|
||||
--frame <n> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user