// 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 [flags] render one sprite to png/jpg/svg spritec sheet ... [flags] combine sprites into one sheet image spritec info validate a file and print its stats spritec preview draw the sprite in the terminal spritec version print version Render flags: -o output file; its extension picks the format (.png .jpg .svg) --format png | jpg | svg (default: from -o, else png) --scale integer upscale factor, default 1 --bg jpg background color (jpg has no alpha), default #FFFFFF --quality jpg quality 1-100, default 90 Sheet flags (in addition to the render flags): -o output base name; layout is appended automatically --cols number of columns, row-major order (default: one row) Sheet output naming: _x_x. 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) }