Files
claude 23135a96d1 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
2026-07-14 02:22:16 +02:00

314 lines
8.2 KiB
Go

// spritec turns agent-friendly .sprite text files into PNG/JPG/SVG pixel
// art, and combines several sprites into sprite sheets / animation strips.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/pixel-sprite-maker/sprite"
)
var version = "dev"
const usage = `spritec - pixel sprite maker for agents
Usage:
spritec render <file.sprite> [flags] render one sprite to png/jpg/svg
spritec sheet <a.sprite> <b.sprite> ... [flags]
combine sprites into one sheet image
spritec info <file.sprite> validate a file and print its stats
spritec preview <file.sprite> draw the sprite in the terminal
spritec version print version
Render flags:
-o <path> output file; its extension picks the format (.png .jpg .svg)
--format <f> png | jpg | svg (default: from -o, else png)
--scale <n> integer upscale factor, default 1
--bg <color> jpg background color (jpg has no alpha), default #FFFFFF
--quality <n> jpg quality 1-100, default 90
Sheet flags (in addition to the render flags):
-o <name> output base name; layout is appended automatically
--cols <n> number of columns, row-major order (default: one row)
Sheet output naming:
<name>_<cellW>x<cellH>_<cols>x<rows>.<ext>
e.g. walk_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows,
frames are read left-to-right then top-to-bottom (row-major).
The .sprite format:
# comment
sprite: coin optional name
palette:
. = none '.' is transparent by default
k = #000000 hex, rgb(...), CSS names ('gold') and 'none' work
y = gold anything after the color is a comment
grid:
..kk..
.kyyk.
.kyyk.
..kk..
Each grid character is one pixel; every row must be the same width.
Max sprite size: 256x256. Sheets may be bigger, single frames may not.
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "render":
cmdRender(os.Args[2:])
case "sheet":
cmdSheet(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("spritec", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'spritec help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args
// (stdlib flag stops at the first positional otherwise).
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...))
}
type renderFlags struct {
out string
format string
scale int
bg string
quality int
}
func addRenderFlags(fs *flag.FlagSet) *renderFlags {
rf := &renderFlags{}
fs.StringVar(&rf.out, "o", "", "output path")
fs.StringVar(&rf.format, "format", "", "png|jpg|svg")
fs.IntVar(&rf.scale, "scale", 1, "integer upscale factor")
fs.StringVar(&rf.bg, "bg", "#FFFFFF", "jpg background color")
fs.IntVar(&rf.quality, "quality", 90, "jpg quality 1-100")
return rf
}
// resolveFormat picks the output format from --format or the -o extension.
func (rf *renderFlags) resolveFormat() (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(rf.out), "."))
if ext == "jpeg" {
ext = "jpg"
}
f := strings.ToLower(rf.format)
switch {
case f == "" && ext == "":
return "png", nil
case f == "":
f = ext
case ext != "" && ext != f:
return "", fmt.Errorf("--format %s conflicts with output extension .%s", f, ext)
}
switch f {
case "png", "jpg", "svg":
return f, nil
}
return "", fmt.Errorf("unsupported format %q (png, jpg or svg)", f)
}
func writeGrid(path, format string, g sprite.PixelGrid, rf *renderFlags) error {
bg, err := sprite.ParseColor(rf.bg)
if err != nil {
return fmt.Errorf("--bg: %w", err)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
switch format {
case "png":
err = sprite.WritePNG(f, g, rf.scale)
case "jpg":
err = sprite.WriteJPG(f, g, rf.scale, bg, rf.quality)
case "svg":
err = sprite.WriteSVG(f, g, rf.scale)
}
if err != nil {
return err
}
return f.Close()
}
func cmdRender(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
rf := addRenderFlags(fs)
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("render takes exactly one .sprite file")
}
in := fs.Arg(0)
s, err := sprite.ParseFile(in)
if err != nil {
die("%v", err)
}
format, err := rf.resolveFormat()
if err != nil {
die("%v", err)
}
out := rf.out
if out == "" {
out = strings.TrimSuffix(in, filepath.Ext(in)) + "." + format
}
if err := writeGrid(out, format, s, rf); err != nil {
die("%v", err)
}
w, h := s.Bounds()
fmt.Printf("%s (%dx%d px, scale %d -> %dx%d)\n", out, w, h, rf.scale, w*rf.scale, h*rf.scale)
}
func cmdSheet(args []string) {
fs := flag.NewFlagSet("sheet", flag.ExitOnError)
rf := addRenderFlags(fs)
cols := fs.Int("cols", 0, "columns in the sheet (default: all sprites in one row)")
parseInterspersed(fs, args)
if fs.NArg() < 1 {
die("sheet needs at least one .sprite file")
}
var sprites []*sprite.Sprite
for _, path := range fs.Args() {
s, err := sprite.ParseFile(path)
if err != nil {
die("%v", err)
}
sprites = append(sprites, s)
}
sh, err := sprite.NewSheet(sprites, *cols)
if err != nil {
die("%v", err)
}
format, err := rf.resolveFormat()
if err != nil {
die("%v", err)
}
base := rf.out
if base == "" {
base = "sheet"
}
base = strings.TrimSuffix(base, filepath.Ext(base))
out := sh.FileBase(base) + "." + format
if err := writeGrid(out, format, sh, rf); err != nil {
die("%v", err)
}
w, h := sh.Bounds()
fmt.Printf("%s (%d frames of %dx%d px in %dx%d grid, total %dx%d px)\n",
out, len(sprites), sh.CellW, sh.CellH, sh.Cols, sh.Rows, w*rf.scale, h*rf.scale)
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .sprite file")
}
s, err := sprite.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("sprite: %s\n", s.Name)
fmt.Printf("size: %dx%d px\n", s.W, s.H)
fmt.Printf("palette: %d colors\n", len(s.Keys))
usage := s.UsageCount()
for _, k := range s.Keys {
note := ""
if usage[k] == 0 {
note = " (unused)"
}
fmt.Printf(" %s = %-9s %5d px%s\n", string(k), sprite.FormatColor(s.Palette[k]), usage[k], note)
}
if _, defined := usage['.']; defined && !contains(s.Keys, '.') {
fmt.Printf(" . = none %5d px (implicit transparent)\n", usage['.'])
}
fmt.Println("valid: yes")
}
func contains(keys []rune, k rune) bool {
for _, x := range keys {
if x == k {
return true
}
}
return false
}
// cmdPreview draws the sprite with truecolor half-blocks, two pixel rows
// per terminal line. Transparent pixels show the terminal background.
func cmdPreview(args []string) {
if len(args) != 1 {
die("preview takes exactly one .sprite file")
}
s, err := sprite.ParseFile(args[0])
if err != nil {
die("%v", err)
}
const reset = "\x1b[0m"
var b strings.Builder
for y := 0; y < s.H; y += 2 {
for x := 0; x < s.W; x++ {
top, bot := s.At(x, y), sprite.Transparent
if y+1 < s.H {
bot = s.At(x, y+1)
}
topOn, botOn := top.A >= 128, bot.A >= 128
switch {
case topOn && botOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s", top.R, top.G, top.B, bot.R, bot.G, bot.B, reset)
case topOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▀%s", top.R, top.G, top.B, reset)
case botOn:
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm▄%s", bot.R, bot.G, bot.B, reset)
default:
b.WriteByte(' ')
}
}
b.WriteByte('\n')
}
fmt.Printf("%s %dx%d px\n%s", s.Name, s.W, s.H, b.String())
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "spritec: "+format+"\n", a...)
os.Exit(1)
}