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