pixel-sprite-maker: spritec - .sprite text format -> png/jpg/svg + sprite sheets

- .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
This commit is contained in:
2026-07-14 02:21:57 +02:00
parent 581e37acd8
commit 23135a96d1
21 changed files with 1444 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
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
}