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:
2026-07-14 02:21:57 +02:00
parent 581e37acd8
commit 23135a96d1
21 changed files with 1444 additions and 0 deletions

View File

@@ -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,
}
}