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 }