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