- .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
314 lines
8.2 KiB
Go
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)
|
|
}
|