- 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
231 lines
5.8 KiB
Go
231 lines
5.8 KiB
Go
// 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:
|
|
// <base>_<cellW>x<cellH>_<cols>x<rows>.<ext>
|
|
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)
|
|
}
|