From 23135a96d15149c4582da33114d2273e7423b661 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 14 Jul 2026 02:21:57 +0200 Subject: [PATCH] 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 _x_x. (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 Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh --- .gitignore | 3 + pixel-sprite-maker/README.md | 86 +++++ pixel-sprite-maker/examples/coin.sprite | 17 + pixel-sprite-maker/examples/heart.sprite | 25 ++ pixel-sprite-maker/examples/out/coin.png | Bin 0 -> 344 bytes pixel-sprite-maker/examples/out/coin.svg | 34 ++ pixel-sprite-maker/examples/out/heart.jpg | Bin 0 -> 1510 bytes .../examples/out/walk_8x8_4x1.png | Bin 0 -> 508 bytes .../examples/out/walkgrid_8x8_2x2.svg | 57 ++++ pixel-sprite-maker/examples/walk_1.sprite | 16 + pixel-sprite-maker/examples/walk_2.sprite | 16 + pixel-sprite-maker/examples/walk_3.sprite | 16 + pixel-sprite-maker/examples/walk_4.sprite | 16 + pixel-sprite-maker/go.mod | 3 + pixel-sprite-maker/main.go | 313 ++++++++++++++++++ pixel-sprite-maker/sprite/color.go | 270 +++++++++++++++ pixel-sprite-maker/sprite/parse.go | 186 +++++++++++ pixel-sprite-maker/sprite/render.go | 75 +++++ pixel-sprite-maker/sprite/sheet.go | 58 ++++ pixel-sprite-maker/sprite/sprite_test.go | 207 ++++++++++++ pixel-sprite-maker/sprite/svg.go | 46 +++ 21 files changed, 1444 insertions(+) create mode 100644 .gitignore create mode 100644 pixel-sprite-maker/README.md create mode 100644 pixel-sprite-maker/examples/coin.sprite create mode 100644 pixel-sprite-maker/examples/heart.sprite create mode 100644 pixel-sprite-maker/examples/out/coin.png create mode 100644 pixel-sprite-maker/examples/out/coin.svg create mode 100644 pixel-sprite-maker/examples/out/heart.jpg create mode 100644 pixel-sprite-maker/examples/out/walk_8x8_4x1.png create mode 100644 pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg create mode 100644 pixel-sprite-maker/examples/walk_1.sprite create mode 100644 pixel-sprite-maker/examples/walk_2.sprite create mode 100644 pixel-sprite-maker/examples/walk_3.sprite create mode 100644 pixel-sprite-maker/examples/walk_4.sprite create mode 100644 pixel-sprite-maker/go.mod create mode 100644 pixel-sprite-maker/main.go create mode 100644 pixel-sprite-maker/sprite/color.go create mode 100644 pixel-sprite-maker/sprite/parse.go create mode 100644 pixel-sprite-maker/sprite/render.go create mode 100644 pixel-sprite-maker/sprite/sheet.go create mode 100644 pixel-sprite-maker/sprite/sprite_test.go create mode 100644 pixel-sprite-maker/sprite/svg.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09f1b5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +*.exe +mesh-tool/examples/downloads/ diff --git a/pixel-sprite-maker/README.md b/pixel-sprite-maker/README.md new file mode 100644 index 0000000..5fd5787 --- /dev/null +++ b/pixel-sprite-maker/README.md @@ -0,0 +1,86 @@ +# pixel-sprite-maker (`spritec`) + +Turns human/agent-readable `.sprite` text files into pixel art (**PNG, JPG, SVG**) +and combines several sprites into **sprite sheets / animation strips**. +Written in Go, zero dependencies, single static binary. + +## Build + +```bash +# Arch/Garuda: sudo pacman -S go +cd pixel-sprite-maker +go build -o build/spritec . # or run the VS Code task "build pixel-sprite-maker" +go test ./... +``` + +## The `.sprite` format + +One file = one sprite. Designed so an agent can *see* the result in the text: + +``` +# comment lines start with '#' +sprite: coin # optional name +palette: + k = #1A1205 dark outline + y = #FFD700 gold + Y = gold CSS names work too — text after the color is a comment +grid: + ..kkkk.. + .kyyyYk. + kyyyYYyk + ..kkkk.. +``` + +Rules: + +- **One character = one pixel.** Every grid row must be the same width. +- Palette keys are exactly one character (any unicode). `#`, `=`, `:` and + whitespace are not allowed as keys — so the number of colors is + practically unlimited (a-z, A-Z, 0-9, `!@%&*`, unicode…). +- `.` is **transparent by default**; override it in the palette if you want. +- Colors: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`, + `rgba(r,g,b,a)` (alpha 0-255 or 0.0-1.0), all CSS named colors, `none`. +- Max **256x256** pixels per sprite. Sheets may exceed this; single frames may not. + +## Commands + +```bash +spritec render coin.sprite -o coin.png --scale 8 # png/jpg/svg from extension +spritec render coin.sprite --format svg # -> coin.svg +spritec render heart.sprite -o heart.jpg --bg '#222034' # jpg has no alpha: pick bg +spritec info coin.sprite # validate + palette stats +spritec preview coin.sprite # draw in the terminal (truecolor) + +# Sprite sheets / animations — row-major (left→right, then top→bottom): +spritec sheet walk_1.sprite walk_2.sprite walk_3.sprite walk_4.sprite -o walk # 1 row +spritec sheet walk_*.sprite --cols 2 -o walkgrid # 2D: 2 columns x 2 rows +``` + +Flags: `-o` output, `--format png|jpg|svg`, `--scale n` (integer nearest-neighbour +upscale), `--bg color` + `--quality 1-100` (jpg only), `--cols n` (sheet only). + +## Sheet output naming — read the layout from the file name + +``` +_x_x. + +walk_8x8_4x1.png = 8x8 px per frame, 4 columns, 1 row (animation strip) +tiles_16x16_4x2.png = 16x16 px per frame, 4 columns, 2 rows +``` + +Frame order is always **row-major**: index = `row * cols + col`, frame 0 is +top-left. If the frame count doesn't fill the grid, trailing cells are +transparent. In game code: + +``` +frameX = (index % cols) * cellW +frameY = (index / cols) * cellH +``` + +## Exit codes & errors + +`0` on success, `1` on any error, `2` on missing command. Parse errors name +the exact line/row/column and what was expected, so an agent can fix the +file without guessing. + +Examples live in [`examples/`](examples/); generated output in `examples/out/`. diff --git a/pixel-sprite-maker/examples/coin.sprite b/pixel-sprite-maker/examples/coin.sprite new file mode 100644 index 0000000..e2ccddb --- /dev/null +++ b/pixel-sprite-maker/examples/coin.sprite @@ -0,0 +1,17 @@ +# A small gold coin, 8x8. +# '.' is transparent by default and never needs a palette entry. +sprite: coin +palette: + k = #1A1205 dark outline + y = #FFD700 gold + Y = #FFF3A0 highlight + s = #B8860B shadow +grid: + ..kkkk.. + .kyyyYk. + kyyyYYsk + kyyYyysk + kyYyyysk + kYyyyssk + .kysssk. + ..kkkk.. diff --git a/pixel-sprite-maker/examples/heart.sprite b/pixel-sprite-maker/examples/heart.sprite new file mode 100644 index 0000000..0a4be13 --- /dev/null +++ b/pixel-sprite-maker/examples/heart.sprite @@ -0,0 +1,25 @@ +# A 16x16 heart with shading, shows named colors and rgb(). +sprite: heart +palette: + k = #2B0A10 outline + r = crimson main red + R = rgb(255, 90, 110) light red + d = #8B1A2B dark red + w = #FFFFFFCC semi-transparent shine +grid: + ................ + ..kkk....kkk.... + .krrRk..kRrrk... + krrRRrkkrRrrrk.. + krRwRrrrrrrrdk.. + krRwwRrrrrrrdk.. + krRwRrrrrrrddk.. + krrRrrrrrrrddk.. + .krrrrrrrrrdk... + ..krrrrrrrdk.... + ...krrrrrdk..... + ....krrrdk...... + .....krdk....... + ......kk........ + ................ + ................ diff --git a/pixel-sprite-maker/examples/out/coin.png b/pixel-sprite-maker/examples/out/coin.png new file mode 100644 index 0000000000000000000000000000000000000000..599476ee9e876e066e1af4e335853dcaa27ef568 GIT binary patch literal 344 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV3hZCaSW-L^JezO+(Ql`4s%a< zPS#5m(p;_RopV599^*zvQ|1kv0Y(gZ2@FeqAN~F^@ox3olOHOqUdnH}Tkcu6&;H%b z^Ct>+`sL4jegAoIq#D}-)(jp8?h6tPVhcFaGfbYJKb{%3Q2k%o(Wm$SmloPushjL) zhAA*)&~>=H|JR)U8+mr!uMgi4t&jg~eSl#LLoZ_nqZIQ6CX<)HnG`S1v0pnW{=?K= zyMD?hFhn&RgDRNy$8y21IqSXAw0yK=nD@E8_TB$F7c`lx^M%*g9&i5`7W3WmAOpyF xm}{5Z+xfaCzu+gx)gVVD1Fd-`$iVRb|Er~%a~gwAxdKCs!PC{xWt~$(697}WjKcr` literal 0 HcmV?d00001 diff --git a/pixel-sprite-maker/examples/out/coin.svg b/pixel-sprite-maker/examples/out/coin.svg new file mode 100644 index 0000000..1c807cd --- /dev/null +++ b/pixel-sprite-maker/examples/out/coin.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pixel-sprite-maker/examples/out/heart.jpg b/pixel-sprite-maker/examples/out/heart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..17b29a1f87ea6d51033b3a947dc4007cf4afc47d GIT binary patch literal 1510 zcmbW0eN@s{7{`CSiGrpeXlpZ1Sz&R?S=q}@8>_~wIL1R^nL5;-Jj5@`~V7!npj3Z+n~)WG2I z$zhbqWD1pn!XWurTmTLiKnWrSQT}tlw*Znqa0obrK}`qHBou~(f)%JL06^hTuLJle zP-x_Ret7=?0x<+Yqc9jW7UO#@lF3KT04xbdo)){@FO;2zpPo&bfB2NxKWfFbHtO2$ z(HRT2<`e}G!onwiFlFW}TJ-EW3*#0oUb6I)m0vKwj9;~S&ARnT$>4^Ko3?$uJ$=W{ zT^YG~d-m?zf8gM^M~)sVK3*dHvGnwrvhuSP=c+FLT>VSUrOUO~Z!|RClr=T~+TNkK zeW&y8z5B|?Ju0=P_lb63@Y!F6=f)Q=hb-2yahu&S;dG$@%o`kJe*^Rm4+-HxW3d=4 z-p7MN??Z$kVR6%9{m9GN__XZM>GKc!Q&yZ3Uuz49TCjGMx;3Yp5H@4sz)Xt|={3>& z0Tul(qPIYAd0;INh(RF@!;k<5FfmmaE&b+DRdX%9%Vl&3LN%^phdg+K#$x6sDK!aG zbZXb8&RJ2xrnwR`h0V;XOk+~Jz_<+EeQl~SJu-OYmE>w(0~p85<>x~35{)hRRbk}l zS7pextE^~Y^aV6>^^nlnpE}Ro`l_&I_#;}opf6SLa938!(;f1~j`A&4LRUuX!Lq z#vVq?grJS!Ss|_9y{vUEOW^V{gNK&Y9Fwce0)2ksuB;mmc5|G+>5P9gu$ik}Y1Wp! z#_@;(X{tRme0SD9**a(ZcS{nX@ZLfg5Q%;=Y1Ibrm^lwg=98RDyFeAy+QMgZKNLc= zam_LPSU3#q7K|7lC)pYcO1Ye*?eZ*xOEh~h(T%{t0F-7;D0slFrCJkmK(UR(%NJc3 z6@>{hAwhpmPLAh_&aHpC`DDe;D&bwvhUh9G;L`wIaix!^xS|yIcsYX%XD$q66&ACY z6i^fSeWeQVr)yMHOOy1vAYp$d48#iTMz4+^$K`+qK0hv{hwe#sr5Yu-D`_5?i6OXm zugjj5Z-s#adkM}mA^m);fx!~5?B#O$5xW~#eV+6qb>^luN_>Z9vAY=tWODl>DOs8+ zCD#o_Bq}COdQ)JaPcK$>GpV-DXW|roq!CH8nqg zEX3sWPKZ97FZy%;iFnz~vU)a?s?0u0VSkQ8#<4p^bqV6|coAm{;_F+>o5v&Gv?VgR z{QAurJ1r(*&oy~OG1}+yIQJO|VoJx16g90uYFl?^9&e~HO7S~OuniJDhf>ck z-#nKBs@WG!Qi)lxbTD?RPP?fMsrTwT$fyJV3YmB0HzZ#i$&_5(n`W8XjuiRPo_4T= z&HcDuJyd9@u*mEuX%Hi3uJ@@!<^Z?UA=Q5QqT3Ft9gHBw)=yg+8jFO{b)_eKC4V2m GPyPlbv$k>o literal 0 HcmV?d00001 diff --git a/pixel-sprite-maker/examples/out/walk_8x8_4x1.png b/pixel-sprite-maker/examples/out/walk_8x8_4x1.png new file mode 100644 index 0000000000000000000000000000000000000000..04c7bc8000058ff1e9202a670ecc9272ce58444c GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K595~p3(!>0mQTEP zCT@+?+UwWt*Iu`HTJh)C_0PZ7p2`0=@A`Y&|NrNSvoG^yP?*8sV8Garz{GHng`tU) zfhGH&ti#g}-|l?*dHU_+o9|LtKF{9o`u^uzOqIe69BK>#K3FBf=2^zgt=_k@+V(Z? z-_nYDi=W@`Jzuu`eEiP6_e*WX>%aW*j<2`IYQWU@tSjaj=Y_3~+%LcP>+k7y_3yvm z4|hAi|I63y%t#J}TOaY$y5ZFKy7GC}GgmG>A8*^f@78x){)AW0_Wdiv>OQa+KRyU*G3``E`>KHAo;ft@$aLQkqxu_5YK#^MR`V_Wt|V o^)-L@{)?Y)V^#mgoPpv0|E+y}f4O@N!$Fbj>FVdQ&MBb@0Bt1CtpET3 literal 0 HcmV?d00001 diff --git a/pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg b/pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg new file mode 100644 index 0000000..f366118 --- /dev/null +++ b/pixel-sprite-maker/examples/out/walkgrid_8x8_2x2.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pixel-sprite-maker/examples/walk_1.sprite b/pixel-sprite-maker/examples/walk_1.sprite new file mode 100644 index 0000000..db71425 --- /dev/null +++ b/pixel-sprite-maker/examples/walk_1.sprite @@ -0,0 +1,16 @@ +# Frame 1/4 of a tiny walking guy, 8x8. Legs together. +sprite: walk_1 +palette: + k = #101018 outline / hair + s = #F2C09A skin + b = #2E5FB7 shirt + d = #22406E pants +grid: + ..kkk... + ..kss... + ..kss... + .kbbbk.. + ..bbb... + ..ddd... + ..d.d... + ..k.k... diff --git a/pixel-sprite-maker/examples/walk_2.sprite b/pixel-sprite-maker/examples/walk_2.sprite new file mode 100644 index 0000000..534974c --- /dev/null +++ b/pixel-sprite-maker/examples/walk_2.sprite @@ -0,0 +1,16 @@ +# Frame 2/4 - right leg forward. +sprite: walk_2 +palette: + k = #101018 outline / hair + s = #F2C09A skin + b = #2E5FB7 shirt + d = #22406E pants +grid: + ..kkk... + ..kss... + ..kss... + .kbbbk.. + ..bbb... + ..ddd... + .d...d.. + .k...k.. diff --git a/pixel-sprite-maker/examples/walk_3.sprite b/pixel-sprite-maker/examples/walk_3.sprite new file mode 100644 index 0000000..213fbda --- /dev/null +++ b/pixel-sprite-maker/examples/walk_3.sprite @@ -0,0 +1,16 @@ +# Frame 3/4 - legs together again (same pose as 1, arms swung). +sprite: walk_3 +palette: + k = #101018 outline / hair + s = #F2C09A skin + b = #2E5FB7 shirt + d = #22406E pants +grid: + ..kkk... + ..kss... + ..kss... + ..bbbk.. + .kbbb... + ..ddd... + ..d.d... + ..k.k... diff --git a/pixel-sprite-maker/examples/walk_4.sprite b/pixel-sprite-maker/examples/walk_4.sprite new file mode 100644 index 0000000..c81de20 --- /dev/null +++ b/pixel-sprite-maker/examples/walk_4.sprite @@ -0,0 +1,16 @@ +# Frame 4/4 - left leg forward. +sprite: walk_4 +palette: + k = #101018 outline / hair + s = #F2C09A skin + b = #2E5FB7 shirt + d = #22406E pants +grid: + ..kkk... + ..kss... + ..kss... + .kbbbk.. + ..bbb... + ..ddd... + ..dd.... + .k..k... diff --git a/pixel-sprite-maker/go.mod b/pixel-sprite-maker/go.mod new file mode 100644 index 0000000..33f49c1 --- /dev/null +++ b/pixel-sprite-maker/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/pixel-sprite-maker + +go 1.24 diff --git a/pixel-sprite-maker/main.go b/pixel-sprite-maker/main.go new file mode 100644 index 0000000..98b84a9 --- /dev/null +++ b/pixel-sprite-maker/main.go @@ -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 [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) +} diff --git a/pixel-sprite-maker/sprite/color.go b/pixel-sprite-maker/sprite/color.go new file mode 100644 index 0000000..4ba2048 --- /dev/null +++ b/pixel-sprite-maker/sprite/color.go @@ -0,0 +1,270 @@ +package sprite + +import ( + "fmt" + "image/color" + "strconv" + "strings" +) + +// Transparent is the color used for pixels that should not be drawn. +var Transparent = color.NRGBA{0, 0, 0, 0} + +// ParseColor accepts: +// +// none | transparent | - -> fully transparent +// #RGB #RGBA #RRGGBB #RRGGBBAA -> hex +// rgb(r,g,b) rgba(r,g,b,a) -> 0-255 channels, alpha 0-255 or 0.0-1.0 +// CSS named colors -> "red", "rebeccapurple", ... +func ParseColor(s string) (color.NRGBA, error) { + t := strings.ToLower(strings.TrimSpace(s)) + if t == "" { + return Transparent, fmt.Errorf("empty color value") + } + switch t { + case "none", "transparent", "-": + return Transparent, nil + } + if strings.HasPrefix(t, "#") { + return parseHex(t) + } + if strings.HasPrefix(t, "rgb(") || strings.HasPrefix(t, "rgba(") { + return parseRGBFunc(t) + } + if c, ok := cssColors[t]; ok { + return c, nil + } + return Transparent, fmt.Errorf("unknown color %q (use #RRGGBB, rgb(r,g,b), a CSS color name, or 'none')", s) +} + +func parseHex(t string) (color.NRGBA, error) { + h := t[1:] + var r, g, b, a uint64 + var err error + dup := func(s string) (uint64, error) { + v, err := strconv.ParseUint(s, 16, 8) + return v*16 + v, err + } + a = 255 + switch len(h) { + case 3, 4: + if r, err = dup(h[0:1]); err == nil { + if g, err = dup(h[1:2]); err == nil { + b, err = dup(h[2:3]) + } + } + if err == nil && len(h) == 4 { + a, err = dup(h[3:4]) + } + case 6, 8: + if r, err = strconv.ParseUint(h[0:2], 16, 8); err == nil { + if g, err = strconv.ParseUint(h[2:4], 16, 8); err == nil { + b, err = strconv.ParseUint(h[4:6], 16, 8) + } + } + if err == nil && len(h) == 8 { + a, err = strconv.ParseUint(h[6:8], 16, 8) + } + default: + return Transparent, fmt.Errorf("hex color %q must be #RGB, #RGBA, #RRGGBB or #RRGGBBAA", t) + } + if err != nil { + return Transparent, fmt.Errorf("invalid hex color %q", t) + } + return color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)}, nil +} + +func parseRGBFunc(t string) (color.NRGBA, error) { + open := strings.Index(t, "(") + close := strings.Index(t, ")") + if close < open { + return Transparent, fmt.Errorf("invalid color %q: missing ')'", t) + } + parts := strings.Split(t[open+1:close], ",") + if len(parts) != 3 && len(parts) != 4 { + return Transparent, fmt.Errorf("invalid color %q: want rgb(r,g,b) or rgba(r,g,b,a)", t) + } + var ch [4]uint8 + ch[3] = 255 + for i, p := range parts { + p = strings.TrimSpace(p) + if i == 3 && strings.Contains(p, ".") { + // CSS-style fractional alpha 0.0 - 1.0 + f, err := strconv.ParseFloat(p, 64) + if err != nil || f < 0 || f > 1 { + return Transparent, fmt.Errorf("invalid alpha %q in %q (want 0.0-1.0 or 0-255)", p, t) + } + ch[3] = uint8(f*255 + 0.5) + continue + } + v, err := strconv.ParseUint(p, 10, 8) + if err != nil { + return Transparent, fmt.Errorf("invalid channel %q in %q (want 0-255)", p, t) + } + ch[i] = uint8(v) + } + return color.NRGBA{ch[0], ch[1], ch[2], ch[3]}, nil +} + +// FormatColor renders a color the way it should appear in a .sprite file. +func FormatColor(c color.NRGBA) string { + if c.A == 0 { + return "none" + } + if c.A == 255 { + return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) + } + return fmt.Sprintf("#%02X%02X%02X%02X", c.R, c.G, c.B, c.A) +} + +// cssColors is the full CSS Color Module Level 4 named-color list. +var cssColors = map[string]color.NRGBA{ + "aliceblue": {240, 248, 255, 255}, + "antiquewhite": {250, 235, 215, 255}, + "aqua": {0, 255, 255, 255}, + "aquamarine": {127, 255, 212, 255}, + "azure": {240, 255, 255, 255}, + "beige": {245, 245, 220, 255}, + "bisque": {255, 228, 196, 255}, + "black": {0, 0, 0, 255}, + "blanchedalmond": {255, 235, 205, 255}, + "blue": {0, 0, 255, 255}, + "blueviolet": {138, 43, 226, 255}, + "brown": {165, 42, 42, 255}, + "burlywood": {222, 184, 135, 255}, + "cadetblue": {95, 158, 160, 255}, + "chartreuse": {127, 255, 0, 255}, + "chocolate": {210, 105, 30, 255}, + "coral": {255, 127, 80, 255}, + "cornflowerblue": {100, 149, 237, 255}, + "cornsilk": {255, 248, 220, 255}, + "crimson": {220, 20, 60, 255}, + "cyan": {0, 255, 255, 255}, + "darkblue": {0, 0, 139, 255}, + "darkcyan": {0, 139, 139, 255}, + "darkgoldenrod": {184, 134, 11, 255}, + "darkgray": {169, 169, 169, 255}, + "darkgreen": {0, 100, 0, 255}, + "darkgrey": {169, 169, 169, 255}, + "darkkhaki": {189, 183, 107, 255}, + "darkmagenta": {139, 0, 139, 255}, + "darkolivegreen": {85, 107, 47, 255}, + "darkorange": {255, 140, 0, 255}, + "darkorchid": {153, 50, 204, 255}, + "darkred": {139, 0, 0, 255}, + "darksalmon": {233, 150, 122, 255}, + "darkseagreen": {143, 188, 143, 255}, + "darkslateblue": {72, 61, 139, 255}, + "darkslategray": {47, 79, 79, 255}, + "darkslategrey": {47, 79, 79, 255}, + "darkturquoise": {0, 206, 209, 255}, + "darkviolet": {148, 0, 211, 255}, + "deeppink": {255, 20, 147, 255}, + "deepskyblue": {0, 191, 255, 255}, + "dimgray": {105, 105, 105, 255}, + "dimgrey": {105, 105, 105, 255}, + "dodgerblue": {30, 144, 255, 255}, + "firebrick": {178, 34, 34, 255}, + "floralwhite": {255, 250, 240, 255}, + "forestgreen": {34, 139, 34, 255}, + "fuchsia": {255, 0, 255, 255}, + "gainsboro": {220, 220, 220, 255}, + "ghostwhite": {248, 248, 255, 255}, + "gold": {255, 215, 0, 255}, + "goldenrod": {218, 165, 32, 255}, + "gray": {128, 128, 128, 255}, + "green": {0, 128, 0, 255}, + "greenyellow": {173, 255, 47, 255}, + "grey": {128, 128, 128, 255}, + "honeydew": {240, 255, 240, 255}, + "hotpink": {255, 105, 180, 255}, + "indianred": {205, 92, 92, 255}, + "indigo": {75, 0, 130, 255}, + "ivory": {255, 255, 240, 255}, + "khaki": {240, 230, 140, 255}, + "lavender": {230, 230, 250, 255}, + "lavenderblush": {255, 240, 245, 255}, + "lawngreen": {124, 252, 0, 255}, + "lemonchiffon": {255, 250, 205, 255}, + "lightblue": {173, 216, 230, 255}, + "lightcoral": {240, 128, 128, 255}, + "lightcyan": {224, 255, 255, 255}, + "lightgoldenrodyellow": {250, 250, 210, 255}, + "lightgray": {211, 211, 211, 255}, + "lightgreen": {144, 238, 144, 255}, + "lightgrey": {211, 211, 211, 255}, + "lightpink": {255, 182, 193, 255}, + "lightsalmon": {255, 160, 122, 255}, + "lightseagreen": {32, 178, 170, 255}, + "lightskyblue": {135, 206, 250, 255}, + "lightslategray": {119, 136, 153, 255}, + "lightslategrey": {119, 136, 153, 255}, + "lightsteelblue": {176, 196, 222, 255}, + "lightyellow": {255, 255, 224, 255}, + "lime": {0, 255, 0, 255}, + "limegreen": {50, 205, 50, 255}, + "linen": {250, 240, 230, 255}, + "magenta": {255, 0, 255, 255}, + "maroon": {128, 0, 0, 255}, + "mediumaquamarine": {102, 205, 170, 255}, + "mediumblue": {0, 0, 205, 255}, + "mediumorchid": {186, 85, 211, 255}, + "mediumpurple": {147, 112, 219, 255}, + "mediumseagreen": {60, 179, 113, 255}, + "mediumslateblue": {123, 104, 238, 255}, + "mediumspringgreen": {0, 250, 154, 255}, + "mediumturquoise": {72, 209, 204, 255}, + "mediumvioletred": {199, 21, 133, 255}, + "midnightblue": {25, 25, 112, 255}, + "mintcream": {245, 255, 250, 255}, + "mistyrose": {255, 228, 225, 255}, + "moccasin": {255, 228, 181, 255}, + "navajowhite": {255, 222, 173, 255}, + "navy": {0, 0, 128, 255}, + "oldlace": {253, 245, 230, 255}, + "olive": {128, 128, 0, 255}, + "olivedrab": {107, 142, 35, 255}, + "orange": {255, 165, 0, 255}, + "orangered": {255, 69, 0, 255}, + "orchid": {218, 112, 214, 255}, + "palegoldenrod": {238, 232, 170, 255}, + "palegreen": {152, 251, 152, 255}, + "paleturquoise": {175, 238, 238, 255}, + "palevioletred": {219, 112, 147, 255}, + "papayawhip": {255, 239, 213, 255}, + "peachpuff": {255, 218, 185, 255}, + "peru": {205, 133, 63, 255}, + "pink": {255, 192, 203, 255}, + "plum": {221, 160, 221, 255}, + "powderblue": {176, 224, 230, 255}, + "purple": {128, 0, 128, 255}, + "rebeccapurple": {102, 51, 153, 255}, + "red": {255, 0, 0, 255}, + "rosybrown": {188, 143, 143, 255}, + "royalblue": {65, 105, 225, 255}, + "saddlebrown": {139, 69, 19, 255}, + "salmon": {250, 128, 114, 255}, + "sandybrown": {244, 164, 96, 255}, + "seagreen": {46, 139, 87, 255}, + "seashell": {255, 245, 238, 255}, + "sienna": {160, 82, 45, 255}, + "silver": {192, 192, 192, 255}, + "skyblue": {135, 206, 235, 255}, + "slateblue": {106, 90, 205, 255}, + "slategray": {112, 128, 144, 255}, + "slategrey": {112, 128, 144, 255}, + "snow": {255, 250, 250, 255}, + "springgreen": {0, 255, 127, 255}, + "steelblue": {70, 130, 180, 255}, + "tan": {210, 180, 140, 255}, + "teal": {0, 128, 128, 255}, + "thistle": {216, 191, 216, 255}, + "tomato": {255, 99, 71, 255}, + "turquoise": {64, 224, 208, 255}, + "violet": {238, 130, 238, 255}, + "wheat": {245, 222, 179, 255}, + "white": {255, 255, 255, 255}, + "whitesmoke": {245, 245, 245, 255}, + "yellow": {255, 255, 0, 255}, + "yellowgreen": {154, 205, 50, 255}, +} diff --git a/pixel-sprite-maker/sprite/parse.go b/pixel-sprite-maker/sprite/parse.go new file mode 100644 index 0000000..2c4e0dc --- /dev/null +++ b/pixel-sprite-maker/sprite/parse.go @@ -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 ' = '", 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 +} diff --git a/pixel-sprite-maker/sprite/render.go b/pixel-sprite-maker/sprite/render.go new file mode 100644 index 0000000..4b90376 --- /dev/null +++ b/pixel-sprite-maker/sprite/render.go @@ -0,0 +1,75 @@ +package sprite + +import ( + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "io" +) + +// PixelGrid is anything that can be rendered pixel by pixel: a single +// Sprite or a composed Sheet. +type PixelGrid interface { + Bounds() (w, h int) + At(x, y int) color.NRGBA +} + +// Image renders a PixelGrid to an NRGBA image, scaled up by the integer +// factor scale (nearest neighbour, keeps pixels crisp). +func Image(g PixelGrid, scale int) *image.NRGBA { + if scale < 1 { + scale = 1 + } + w, h := g.Bounds() + img := image.NewNRGBA(image.Rect(0, 0, w*scale, h*scale)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := g.At(x, y) + for dy := 0; dy < scale; dy++ { + for dx := 0; dx < scale; dx++ { + img.SetNRGBA(x*scale+dx, y*scale+dy, c) + } + } + } + } + return img +} + +// WritePNG encodes g as PNG with transparency preserved. +func WritePNG(w io.Writer, g PixelGrid, scale int) error { + return png.Encode(w, Image(g, scale)) +} + +// WriteJPG encodes g as JPEG. JPEG has no alpha channel, so transparent +// pixels are composited over bg first. +func WriteJPG(w io.Writer, g PixelGrid, scale int, bg color.NRGBA, quality int) error { + if quality < 1 || quality > 100 { + return fmt.Errorf("jpg quality %d out of range 1-100", quality) + } + src := Image(g, scale) + b := src.Bounds() + flat := image.NewRGBA(b) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + flat.Set(x, y, blendOver(src.NRGBAAt(x, y), bg)) + } + } + return jpeg.Encode(w, flat, &jpeg.Options{Quality: quality}) +} + +// blendOver composites src over an opaque background color. +func blendOver(src, bg color.NRGBA) color.NRGBA { + if src.A == 255 { + return src + } + a := uint32(src.A) + inv := 255 - a + return color.NRGBA{ + R: uint8((uint32(src.R)*a + uint32(bg.R)*inv) / 255), + G: uint8((uint32(src.G)*a + uint32(bg.G)*inv) / 255), + B: uint8((uint32(src.B)*a + uint32(bg.B)*inv) / 255), + A: 255, + } +} diff --git a/pixel-sprite-maker/sprite/sheet.go b/pixel-sprite-maker/sprite/sheet.go new file mode 100644 index 0000000..1ce3434 --- /dev/null +++ b/pixel-sprite-maker/sprite/sheet.go @@ -0,0 +1,58 @@ +package sprite + +import ( + "fmt" + "image/color" +) + +// Sheet lays out several equally sized sprites in a row-major grid: +// index 0 is top-left, then left-to-right, then next row. A 1D strip is +// simply a sheet with one row (or one column). +type Sheet struct { + Sprites []*Sprite + Cols, Rows int + CellW, CellH int +} + +// NewSheet composes sprites into a sheet with the given number of columns. +// cols <= 0 puts everything in a single row. Every sprite must have the +// same dimensions so the sheet can be indexed cell by cell. +func NewSheet(sprites []*Sprite, cols int) (*Sheet, error) { + if len(sprites) == 0 { + return nil, fmt.Errorf("a sheet needs at least one sprite") + } + w, h := sprites[0].Bounds() + for _, sp := range sprites[1:] { + sw, sh := sp.Bounds() + if sw != w || sh != h { + return nil, fmt.Errorf("sprite %q is %dx%d but %q is %dx%d — all sprites in a sheet must be the same size", + sp.Name, sw, sh, sprites[0].Name, w, h) + } + } + if cols <= 0 || cols > len(sprites) { + cols = len(sprites) + } + rows := (len(sprites) + cols - 1) / cols + return &Sheet{Sprites: sprites, Cols: cols, Rows: rows, CellW: w, CellH: h}, nil +} + +// Bounds returns the total sheet size in pixels. +func (sh *Sheet) Bounds() (w, h int) { return sh.Cols * sh.CellW, sh.Rows * sh.CellH } + +// At returns the pixel at (x, y). Cells past the last sprite (when the +// sprite count doesn't fill the grid) are transparent. +func (sh *Sheet) At(x, y int) color.NRGBA { + col, row := x/sh.CellW, y/sh.CellH + idx := row*sh.Cols + col + if idx >= len(sh.Sprites) { + return Transparent + } + return sh.Sprites[idx].At(x%sh.CellW, y%sh.CellH) +} + +// FileBase appends the layout to an output name so the file itself +// documents cell size and orientation: _x_x +// e.g. walk_16x16_4x2 = 16x16 cells, 4 columns, 2 rows, read row by row. +func (sh *Sheet) FileBase(base string) string { + return fmt.Sprintf("%s_%dx%d_%dx%d", base, sh.CellW, sh.CellH, sh.Cols, sh.Rows) +} diff --git a/pixel-sprite-maker/sprite/sprite_test.go b/pixel-sprite-maker/sprite/sprite_test.go new file mode 100644 index 0000000..5a5e619 --- /dev/null +++ b/pixel-sprite-maker/sprite/sprite_test.go @@ -0,0 +1,207 @@ +package sprite + +import ( + "bytes" + "encoding/xml" + "image/color" + "strings" + "testing" +) + +const coin = ` +# a tiny coin +sprite: coin +palette: + k = #000000 + y = gold + Y = #FFF3A0 highlight +grid: + .kk. + kyYk + kyyk + .kk. +` + +func parseOK(t *testing.T, src string) *Sprite { + t.Helper() + s, err := Parse(strings.NewReader(src)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + return s +} + +func TestParseBasics(t *testing.T) { + s := parseOK(t, coin) + if s.Name != "coin" { + t.Errorf("name = %q, want coin", s.Name) + } + if s.W != 4 || s.H != 4 { + t.Errorf("size = %dx%d, want 4x4", s.W, s.H) + } + if got := s.At(0, 0); got.A != 0 { + t.Errorf("(0,0) should be transparent via the implicit '.', got %v", got) + } + if got := s.At(1, 1); got != (color.NRGBA{255, 215, 0, 255}) { + t.Errorf("(1,1) = %v, want gold", got) + } + if got := s.At(2, 1); got != (color.NRGBA{255, 243, 160, 255}) { + t.Errorf("(2,1) = %v, want #FFF3A0", got) + } +} + +func TestParseErrors(t *testing.T) { + cases := map[string]string{ + "ragged rows": "palette:\n a = red\ngrid:\naa\naaa\n", + "unknown key": "palette:\n a = red\ngrid:\nab\naa\n", + "bad color": "palette:\n a = notacolor\ngrid:\na\n", + "no grid": "palette:\n a = red\n", + "dup key": "palette:\n a = red\n a = blue\ngrid:\na\n", + "multirune key": "palette:\n ab = red\ngrid:\na\n", + "colon key": "palette:\n : = red\ngrid:\n.\n", + } + for name, src := range cases { + if _, err := Parse(strings.NewReader(src)); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func TestMaxSize(t *testing.T) { + row := strings.Repeat("a", 257) + src := "palette:\n a = red\ngrid:\n" + row + "\n" + if _, err := Parse(strings.NewReader(src)); err == nil { + t.Error("257 px wide sprite should be rejected") + } + ok := "palette:\n a = red\ngrid:\n" + strings.Repeat(strings.Repeat("a", 256)+"\n", 256) + s := parseOK(t, ok) + if s.W != 256 || s.H != 256 { + t.Errorf("size = %dx%d, want 256x256", s.W, s.H) + } +} + +func TestParseColorForms(t *testing.T) { + cases := map[string]color.NRGBA{ + "none": {0, 0, 0, 0}, + "-": {0, 0, 0, 0}, + "#F00": {255, 0, 0, 255}, + "#F00A": {255, 0, 0, 170}, + "#00FF00": {0, 255, 0, 255}, + "#00FF0080": {0, 255, 0, 128}, + "rgb(1,2,3)": {1, 2, 3, 255}, + "rgba(1,2,3,64)": {1, 2, 3, 64}, + "rgba(1, 2, 3, .5)": {1, 2, 3, 128}, + "RebeccaPurple": {102, 51, 153, 255}, + } + for in, want := range cases { + got, err := ParseColor(in) + if err != nil { + t.Errorf("ParseColor(%q): %v", in, err) + continue + } + if got != want { + t.Errorf("ParseColor(%q) = %v, want %v", in, got, want) + } + } + for _, bad := range []string{"", "#12345", "rgb(300,0,0)", "purpleish"} { + if _, err := ParseColor(bad); err == nil { + t.Errorf("ParseColor(%q): expected error", bad) + } + } +} + +func TestImageAndScale(t *testing.T) { + s := parseOK(t, coin) + img := Image(s, 3) + if b := img.Bounds(); b.Dx() != 12 || b.Dy() != 12 { + t.Fatalf("scaled bounds = %v, want 12x12", b) + } + // pixel (1,1) is gold -> block at (3..5, 3..5) + if got := img.NRGBAAt(4, 4); got != (color.NRGBA{255, 215, 0, 255}) { + t.Errorf("scaled gold pixel = %v", got) + } + if got := img.NRGBAAt(0, 0); got.A != 0 { + t.Errorf("corner should stay transparent, got %v", got) + } +} + +func TestSVGOutput(t *testing.T) { + s := parseOK(t, coin) + var buf bytes.Buffer + if err := WriteSVG(&buf, s, 10); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, `viewBox="0 0 4 4"`) || !strings.Contains(out, `width="40"`) { + t.Errorf("svg header wrong:\n%s", out) + } + // row 2 (y=1) has runs k, yY?, no: k y Y k -> y and Y differ, so no merge. + // row 3 (y=2) k yy k -> the two golds merge into one rect of width 2. + if !strings.Contains(out, ``) { + t.Errorf("expected RLE-merged gold rect:\n%s", out) + } + var doc struct{ XMLName xml.Name } + if err := xml.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Errorf("svg is not valid XML: %v", err) + } +} + +func TestSheetLayoutAndNaming(t *testing.T) { + a := parseOK(t, coin) + b := parseOK(t, coin) + c := parseOK(t, coin) + sh, err := NewSheet([]*Sprite{a, b, c}, 2) + if err != nil { + t.Fatal(err) + } + if sh.Cols != 2 || sh.Rows != 2 { + t.Errorf("layout = %dx%d, want 2x2", sh.Cols, sh.Rows) + } + if w, h := sh.Bounds(); w != 8 || h != 8 { + t.Errorf("bounds = %dx%d, want 8x8", w, h) + } + if got := sh.FileBase("walk"); got != "walk_4x4_2x2" { + t.Errorf("FileBase = %q, want walk_4x4_2x2", got) + } + // cell (1,1) in frame 2 (top-right) is gold + if got := sh.At(5, 1); got != (color.NRGBA{255, 215, 0, 255}) { + t.Errorf("sheet pixel in frame 2 = %v, want gold", got) + } + // 4th cell (bottom-right) has no sprite -> transparent + if got := sh.At(7, 7); got.A != 0 { + t.Errorf("empty cell should be transparent, got %v", got) + } +} + +func TestSheetSizeMismatch(t *testing.T) { + a := parseOK(t, coin) + b := parseOK(t, "palette:\n a = red\ngrid:\naa\n") + if _, err := NewSheet([]*Sprite{a, b}, 0); err == nil { + t.Error("mismatched sprite sizes should be rejected") + } +} + +func TestSheetDefaultSingleRow(t *testing.T) { + a := parseOK(t, coin) + sh, err := NewSheet([]*Sprite{a, a, a}, 0) + if err != nil { + t.Fatal(err) + } + if sh.Cols != 3 || sh.Rows != 1 { + t.Errorf("default layout = %dx%d, want 3x1", sh.Cols, sh.Rows) + } +} + +func TestJPGComposite(t *testing.T) { + s := parseOK(t, coin) + var buf bytes.Buffer + if err := WriteJPG(&buf, s, 1, color.NRGBA{255, 255, 255, 255}, 90); err != nil { + t.Fatal(err) + } + if buf.Len() == 0 { + t.Error("empty jpg output") + } + if err := WriteJPG(&buf, s, 1, Transparent, 150); err == nil { + t.Error("quality 150 should be rejected") + } +} diff --git a/pixel-sprite-maker/sprite/svg.go b/pixel-sprite-maker/sprite/svg.go new file mode 100644 index 0000000..a789f4b --- /dev/null +++ b/pixel-sprite-maker/sprite/svg.go @@ -0,0 +1,46 @@ +package sprite + +import ( + "fmt" + "io" +) + +// WriteSVG encodes g as an SVG where each horizontal run of same-colored +// pixels becomes one . shape-rendering="crispEdges" keeps the pixel +// look at any zoom. scale only affects the document width/height; the +// viewBox stays in pixel units. +func WriteSVG(w io.Writer, g PixelGrid, scale int) error { + if scale < 1 { + scale = 1 + } + gw, gh := g.Bounds() + _, err := fmt.Fprintf(w, + ``+"\n", + gw*scale, gh*scale, gw, gh) + if err != nil { + return err + } + for y := 0; y < gh; y++ { + for x := 0; x < gw; { + c := g.At(x, y) + run := 1 + for x+run < gw && g.At(x+run, y) == c { + run++ + } + if c.A > 0 { + opacity := "" + if c.A < 255 { + opacity = fmt.Sprintf(` fill-opacity="%.3f"`, float64(c.A)/255) + } + _, err = fmt.Fprintf(w, ``+"\n", + x, y, run, c.R, c.G, c.B, opacity) + if err != nil { + return err + } + } + x += run + } + } + _, err = io.WriteString(w, "\n") + return err +}