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:
313
pixel-sprite-maker/main.go
Normal file
313
pixel-sprite-maker/main.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user