- .sprite format: single-char palette keys (hex/rgb()/CSS names/none), grid rows, '.' transparent by default, max 256x256 per sprite - render/sheet/info/preview subcommands; sheets name themselves <base>_<cellW>x<cellH>_<cols>x<rows>.<ext> (row-major) - SVG output RLE-merges pixel runs; JPG composites over --bg - go tests for parser, colors, scaling, svg, sheet layout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
187 lines
4.9 KiB
Go
187 lines
4.9 KiB
Go
package sprite
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"image/color"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// MaxSize is the maximum width/height of a single sprite in pixels.
|
|
const MaxSize = 256
|
|
|
|
// Sprite is a parsed .sprite file: a palette of single-rune keys and a
|
|
// rectangular grid of those keys.
|
|
type Sprite struct {
|
|
Name string
|
|
W, H int
|
|
Palette map[rune]color.NRGBA
|
|
Keys []rune // palette keys in file order
|
|
Rows [][]rune // H rows of exactly W palette keys
|
|
}
|
|
|
|
// At returns the color of pixel (x, y). Out-of-range pixels are transparent.
|
|
func (s *Sprite) At(x, y int) color.NRGBA {
|
|
if x < 0 || y < 0 || x >= s.W || y >= s.H {
|
|
return Transparent
|
|
}
|
|
return s.Palette[s.Rows[y][x]]
|
|
}
|
|
|
|
// Bounds returns the sprite size in pixels.
|
|
func (s *Sprite) Bounds() (w, h int) { return s.W, s.H }
|
|
|
|
// ParseFile reads a .sprite file from disk. The sprite name defaults to the
|
|
// file name without extension when the file has no "sprite:" line.
|
|
func ParseFile(path string) (*Sprite, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
s, err := Parse(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if s.Name == "" {
|
|
s.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Parse reads the .sprite text format:
|
|
//
|
|
// # comment lines start with '#'
|
|
// sprite: coin (optional name)
|
|
// palette:
|
|
// . = none (key '.' is transparent by default)
|
|
// k = #000000 text after the color is ignored
|
|
// y = gold
|
|
// grid:
|
|
// ..kk..
|
|
// .kyyk.
|
|
//
|
|
// Palette keys are exactly one character and may not be '#', '=', ':' or
|
|
// whitespace. Every grid row must be the same width; max size is 256x256.
|
|
func Parse(r io.Reader) (*Sprite, error) {
|
|
s := &Sprite{Palette: map[rune]color.NRGBA{}}
|
|
const (
|
|
secNone = iota
|
|
secPalette
|
|
secGrid
|
|
)
|
|
section := secNone
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
lineNo := 0
|
|
for sc.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue // comment or blank ('#' is not a legal palette key)
|
|
}
|
|
lower := strings.ToLower(line)
|
|
switch {
|
|
case lower == "palette:":
|
|
section = secPalette
|
|
continue
|
|
case lower == "grid:":
|
|
section = secGrid
|
|
continue
|
|
case strings.HasPrefix(lower, "sprite:"):
|
|
s.Name = strings.TrimSpace(line[len("sprite:"):])
|
|
continue
|
|
}
|
|
switch section {
|
|
case secPalette:
|
|
if err := s.parsePaletteLine(line, lineNo); err != nil {
|
|
return nil, err
|
|
}
|
|
case secGrid:
|
|
row := []rune(line)
|
|
s.Rows = append(s.Rows, row)
|
|
default:
|
|
return nil, fmt.Errorf("line %d: unexpected %q before a 'palette:' or 'grid:' section", lineNo, line)
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, s.validate()
|
|
}
|
|
|
|
func (s *Sprite) parsePaletteLine(line string, lineNo int) error {
|
|
eq := strings.Index(line, "=")
|
|
if eq < 0 {
|
|
return fmt.Errorf("line %d: palette entry %q must look like '<key> = <color>'", lineNo, line)
|
|
}
|
|
keyPart := []rune(strings.TrimSpace(line[:eq]))
|
|
if len(keyPart) != 1 {
|
|
return fmt.Errorf("line %d: palette key %q must be exactly one character", lineNo, strings.TrimSpace(line[:eq]))
|
|
}
|
|
key := keyPart[0]
|
|
if key == '#' || key == '=' || key == ':' {
|
|
return fmt.Errorf("line %d: %q is not allowed as a palette key", lineNo, key)
|
|
}
|
|
if _, dup := s.Palette[key]; dup {
|
|
return fmt.Errorf("line %d: palette key %q defined twice", lineNo, key)
|
|
}
|
|
val := strings.TrimSpace(line[eq+1:])
|
|
// The color is the first token; anything after it is a free-text comment.
|
|
token := val
|
|
if strings.HasPrefix(strings.ToLower(val), "rgb") {
|
|
if close := strings.Index(val, ")"); close >= 0 {
|
|
token = val[:close+1]
|
|
}
|
|
} else if i := strings.IndexAny(val, " \t"); i >= 0 {
|
|
token = val[:i]
|
|
}
|
|
c, err := ParseColor(token)
|
|
if err != nil {
|
|
return fmt.Errorf("line %d: %w", lineNo, err)
|
|
}
|
|
s.Palette[key] = c
|
|
s.Keys = append(s.Keys, key)
|
|
return nil
|
|
}
|
|
|
|
func (s *Sprite) validate() error {
|
|
if len(s.Rows) == 0 {
|
|
return fmt.Errorf("no 'grid:' section with at least one row found")
|
|
}
|
|
// '.' is transparent unless the file overrides it.
|
|
if _, ok := s.Palette['.']; !ok {
|
|
s.Palette['.'] = Transparent
|
|
}
|
|
s.H = len(s.Rows)
|
|
s.W = len(s.Rows[0])
|
|
if s.W > MaxSize || s.H > MaxSize {
|
|
return fmt.Errorf("sprite is %dx%d pixels; the maximum is %dx%d", s.W, s.H, MaxSize, MaxSize)
|
|
}
|
|
for y, row := range s.Rows {
|
|
if len(row) != s.W {
|
|
return fmt.Errorf("grid row %d is %d pixels wide, expected %d (all rows must match row 1)", y+1, len(row), s.W)
|
|
}
|
|
for x, key := range row {
|
|
if _, ok := s.Palette[key]; !ok {
|
|
return fmt.Errorf("grid row %d, column %d: %q is not defined in the palette", y+1, x+1, string(key))
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UsageCount returns how many grid pixels use each palette key.
|
|
func (s *Sprite) UsageCount() map[rune]int {
|
|
n := map[rune]int{}
|
|
for _, row := range s.Rows {
|
|
for _, k := range row {
|
|
n[k]++
|
|
}
|
|
}
|
|
return n
|
|
}
|