- 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
205 lines
5.6 KiB
Go
205 lines
5.6 KiB
Go
// 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)
|
|
}
|