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