Pixellab based system
5
.gitignore
vendored
@@ -21,6 +21,11 @@ out/
|
|||||||
# Generated contact sheets (preview.py, $PREVIEW_OUT defaults to preview/)
|
# Generated contact sheets (preview.py, $PREVIEW_OUT defaults to preview/)
|
||||||
preview/
|
preview/
|
||||||
|
|
||||||
|
# Early PixelLab experiment outputs (the library build itself lives in pixellab_src/, parts_pl*/)
|
||||||
|
pixellab_out/
|
||||||
|
# Cross-process API job lock (pixellab_api.py)
|
||||||
|
pixellab_src/.api.lock
|
||||||
|
|
||||||
# Scratch reports
|
# Scratch reports
|
||||||
size_changes.txt
|
size_changes.txt
|
||||||
|
|
||||||
|
|||||||
53
README.md
@@ -1,6 +1,13 @@
|
|||||||
# sprite_gen
|
# sprite_gen
|
||||||
|
|
||||||
Procedural 96x96 creature sprites for businesses. You give it a business name and logo. It picks a type, builds a creature from parts and recolors it with the logo's brand colors.
|
Procedural creature sprites for businesses. You give it a business name and logo. It picks a type, builds a creature from parts and recolors it with the logo's brand colors.
|
||||||
|
|
||||||
|
There are two part libraries:
|
||||||
|
|
||||||
|
| Library | Size | Art | Registry |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pixellab` (default) | 64x64 | Generated with [PixelLab](https://pixellab.ai), see [PixelLab library](#pixellab-library) | `sprite_parts_pl.py` + `pixellab_library/*.json` |
|
||||||
|
| `classic` | 96x96 | Hand-written text grids | `sprite_parts.py` |
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -27,9 +34,12 @@ python generate_sprite.py "Rosas Landscaping" --type grass --colors "#2E7D32,#FB
|
|||||||
|
|
||||||
# A different creature for the same business
|
# A different creature for the same business
|
||||||
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass --reroll 1
|
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass --reroll 1
|
||||||
|
|
||||||
|
# Both libraries side by side
|
||||||
|
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass --compare
|
||||||
```
|
```
|
||||||
|
|
||||||
Each run writes `sprites/<slug>.png` (96x96), `sprites/<slug>@4x.png` and `sprites/<slug>.json` (type, parts, colors). With `--reroll N`, the file names end in `-rN`.
|
Each run writes `sprites/<slug>.png`, `sprites/<slug>@4x.png` and `sprites/<slug>.json` (library, type, parts, colors). With `--reroll N`, the file names end in `-rN`. With `--compare`, each library writes `<slug>-<library>.*` and `<slug>-compare.png` shows them next to each other at the same height. Both use the same type and colors.
|
||||||
|
|
||||||
| Option | Meaning |
|
| Option | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -37,6 +47,8 @@ Each run writes `sprites/<slug>.png` (96x96), `sprites/<slug>@4x.png` and `sprit
|
|||||||
| `--desc TEXT` | Description sent to the classifier (default: the name) |
|
| `--desc TEXT` | Description sent to the classifier (default: the name) |
|
||||||
| `--colors "#main,#accent"` | Manual colors; overrides the logo |
|
| `--colors "#main,#accent"` | Manual colors; overrides the logo |
|
||||||
| `--reroll N` | Alternate creature; the name alone always gives the same sprite |
|
| `--reroll N` | Alternate creature; the name alone always gives the same sprite |
|
||||||
|
| `--library NAME` | `pixellab` (default) or `classic` |
|
||||||
|
| `--compare` | Render with both libraries and write a comparison sheet |
|
||||||
| `--out DIR` | Output folder (default `sprites`) |
|
| `--out DIR` | Output folder (default `sprites`) |
|
||||||
| `--scale N` | Size of the upscaled copy (default 4, `1` disables it) |
|
| `--scale N` | Size of the upscaled copy (default 4, `1` disables it) |
|
||||||
|
|
||||||
@@ -50,17 +62,40 @@ Use a CSV with the columns `name,logo,type,description`. Only `name` is required
|
|||||||
OPENROUTER_API_KEY=<API_KEY> python generate_sprite.py --batch businesses.csv --out sprites
|
OPENROUTER_API_KEY=<API_KEY> python generate_sprite.py --batch businesses.csv --out sprites
|
||||||
```
|
```
|
||||||
|
|
||||||
## Editing the art
|
Add `--compare` to get both libraries for every row.
|
||||||
|
|
||||||
The parts are text grids in `parts_src/<slot>/<name>.txt`, compiled to `parts/<slot>/<name>.png`. Weights, compatibility and deny lists live in `sprite_parts.py`. See `AI_PIXEL_ART_GUIDE.md` for the palette and anchor rules.
|
## PixelLab library
|
||||||
|
|
||||||
|
Each base creature has a spec in `pixellab_library/<body>.json`: the prompt and seed for its base image, plus one edit prompt per part ("add a pair of gold horns ..."). `pixellab_library.py` generates the base with PixelLab's pixen model and makes each part as an edit of that exact image. The part is the pixel difference between the edit and the base, so it lines up with the base and gets its attach point automatically. The full spec schema and conventions are in the docstring at the top of `pixellab_library.py`.
|
||||||
|
|
||||||
|
Art is drawn in a stand-in palette (teal body, crimson details, gold elemental bits) that maps to the recolorable palette keys. Elemental features can use a fixed-color ramp instead (`fire`, `bluefire`, `ice`, `electric`, `ghostfire`): they keep their own colors whatever the brand, and they only appear on their types. When the generator picks parts, it skips any part that would push the sprite past 64x64, so every part can be offered to every body.
|
||||||
|
|
||||||
|
Needs `PIXELLAB_API_KEY` in the environment or in `.env`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python pixellab_library.py generate pl_fox --base-only # base image only: review it first
|
||||||
|
python pixellab_library.py generate pl_fox # every missing edit (raw images in pixellab_src/pl_fox/)
|
||||||
|
python pixellab_library.py extract pl_fox # grids in parts_pl_src/, PNGs in parts_pl/, diffs in preview/pixellab/diff/
|
||||||
|
python pixellab_library.py preview pl_fox # the body with each of its parts
|
||||||
|
python pixellab_library.py preview --cross # every part on every body, one sheet per slot
|
||||||
|
python pixellab_library.py preview --random 60 # real random picks
|
||||||
|
python pixellab_library.py cost # spend so far (pixellab_src/ledger.jsonl) and balance
|
||||||
|
python validate_parts.py --library pixellab --sample 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
To change a part, edit its spec (new `seed` or wording), then run `generate <body> --parts <part> --force` and `extract <body>`. Regenerating a base deletes that body's old edits.
|
||||||
|
|
||||||
|
## Classic library
|
||||||
|
|
||||||
|
The parts are text grids in `parts_src/<slot>/<name>.txt`, compiled to `parts/<slot>/<name>.png`. Weights, compatibility and deny lists live in `sprite_parts.py`. See `AI_PIXEL_ART_GUIDE.md` for the palette and anchor rules. The PixelLab library's grids in `parts_pl_src/` use the same format, plus the fixed-ramp characters listed in `build_parts.py`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python build_parts.py # compile all grids (or pass part names)
|
python build_parts.py # compile all grids (or pass part names)
|
||||||
python validate_parts.py --sample 3000 # check colors, anchors, canvas fit on random composites
|
python validate_parts.py --library classic --sample 3000 # check colors, anchors, canvas fit on random composites
|
||||||
python preview.py bases # contact sheets in preview/
|
python preview.py --library classic bases # contact sheets in preview/
|
||||||
python preview.py part back plant_bulb # one part on every base it fits
|
python preview.py --library classic part back plant_bulb # one part on every base it fits
|
||||||
python preview.py zoom body=base_fox tail=tail_leaf
|
python preview.py --library classic zoom body=base_fox tail=tail_leaf
|
||||||
python generate_sprite.py --sprite-list SPRITES.md # regenerate the part list and weight report
|
python generate_sprite.py --library classic --sprite-list SPRITES.md # regenerate the part list and weight report
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not run `--make-placeholders --overwrite`, because it replaces the real art with test placeholders.
|
Do not run `--make-placeholders --overwrite`, because it replaces the real art with test placeholders.
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Legend
|
|||||||
a accent_light b accent_base c accent_dark
|
a accent_light b accent_base c accent_dark
|
||||||
x type_light y type_base z type_dark
|
x type_light y type_base z type_dark
|
||||||
w white p pupil
|
w white p pupil
|
||||||
|
fixed-color ramps (light base dark, never recolored, see generate_sprite.FIXED):
|
||||||
|
f g h fire l m n bluefire i j k ice
|
||||||
|
s t u electric q r v ghostfire
|
||||||
P pivot T tail B back A aura ^ head_top
|
P pivot T tail B back A aura ^ head_top
|
||||||
|
|
||||||
Layout sources (parts_src/<slot>/<name>.layout) assemble stamps onto a fixed canvas,
|
Layout sources (parts_src/<slot>/<name>.layout) assemble stamps onto a fixed canvas,
|
||||||
@@ -34,7 +37,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from generate_sprite import KEYS, MARKERS, PUPIL, EYE_WHITE
|
from generate_sprite import EYE_WHITE, FIXED, KEYS, MARKERS, PUPIL
|
||||||
|
|
||||||
# PARTS_SRC / PARTS_DIR let experiments build into a separate library (e.g. proto/).
|
# PARTS_SRC / PARTS_DIR let experiments build into a separate library (e.g. proto/).
|
||||||
SRC = Path(os.environ.get("PARTS_SRC", "parts_src"))
|
SRC = Path(os.environ.get("PARTS_SRC", "parts_src"))
|
||||||
@@ -49,6 +52,10 @@ LEGEND = {
|
|||||||
"P": MARKERS["pivot"], "T": MARKERS["tail"], "B": MARKERS["back"],
|
"P": MARKERS["pivot"], "T": MARKERS["tail"], "B": MARKERS["back"],
|
||||||
"A": MARKERS["aura"], "^": MARKERS["head_top"],
|
"A": MARKERS["aura"], "^": MARKERS["head_top"],
|
||||||
}
|
}
|
||||||
|
# Fixed-color ramp characters, light/base/dark in order
|
||||||
|
FIXED_CHARS = {"fire": "fgh", "bluefire": "lmn", "ice": "ijk", "electric": "stu", "ghostfire": "qrv"}
|
||||||
|
for _ramp, _chars in FIXED_CHARS.items():
|
||||||
|
LEGEND.update(zip(_chars, FIXED[_ramp]))
|
||||||
|
|
||||||
|
|
||||||
class SourceError(Exception):
|
class SourceError(Exception):
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Pipeline: Jev picks a type -> seeded RNG picks a base creature and its variation
|
|||||||
(weighted by type) -> parts snap together via anchor pixels -> markings painted on ->
|
(weighted by type) -> parts snap together via anchor pixels -> markings painted on ->
|
||||||
palette keys swapped for colors pulled from the business logo.
|
palette keys swapped for colors pulled from the business logo.
|
||||||
|
|
||||||
|
Two part libraries share this pipeline (see LIBRARIES): "pixellab" (64x64, art generated with
|
||||||
|
PixelLab, sprite_parts_pl.py) and "classic" (96x96, hand-written grids, sprite_parts.py).
|
||||||
|
|
||||||
Logo colors: transparent pixels, anti-aliased edges and a solid white/black/gray background
|
Logo colors: transparent pixels, anti-aliased edges and a solid white/black/gray background
|
||||||
(the colour filling the image border) are ignored; hued brand colours win over white, black
|
(the colour filling the image border) are ignored; hued brand colours win over white, black
|
||||||
or gray text. A logo that is only white/black (or only its background) still uses that.
|
or gray text. A logo that is only white/black (or only its background) still uses that.
|
||||||
@@ -14,8 +17,9 @@ Usage
|
|||||||
python generate_sprite.py "Rosas Landscaping" logos/rosas.png # Jev picks the type
|
python generate_sprite.py "Rosas Landscaping" logos/rosas.png # Jev picks the type
|
||||||
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass
|
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass
|
||||||
python generate_sprite.py "Rosas Landscaping" --type grass --colors "#2E7D32,#FBC02D"
|
python generate_sprite.py "Rosas Landscaping" --type grass --colors "#2E7D32,#FBC02D"
|
||||||
|
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --compare # both libraries side by side
|
||||||
python generate_sprite.py --batch businesses.csv # columns: name,logo,type,description
|
python generate_sprite.py --batch businesses.csv # columns: name,logo,type,description
|
||||||
python generate_sprite.py --make-placeholders # test art for every part
|
python generate_sprite.py --library classic --make-placeholders # test art for every part
|
||||||
python generate_sprite.py --sprite-list SPRITES.md # art checklist + weight report
|
python generate_sprite.py --sprite-list SPRITES.md # art checklist + weight report
|
||||||
|
|
||||||
Requires: pip install pillow numpy requests
|
Requires: pip install pillow numpy requests
|
||||||
@@ -24,6 +28,7 @@ import argparse
|
|||||||
import colorsys
|
import colorsys
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -34,10 +39,20 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
import sprite_parts as cfg
|
from sprite_parts import TYPE_COLORS, TYPES
|
||||||
|
|
||||||
|
# Part libraries: registry module per library. Each defines NAME, CANVAS (final sprite size),
|
||||||
|
# GROUND_MARGIN (empty rows under the feet), PARTS_DIR, and the picking tables (BODY, SLOTS,
|
||||||
|
# PLAN_WEIGHTS, MARKINGS, MOTIFS, ...) documented in sprite_parts.py.
|
||||||
|
LIBRARIES = {"pixellab": "sprite_parts_pl", "classic": "sprite_parts"}
|
||||||
|
DEFAULT_LIBRARY = "pixellab"
|
||||||
|
|
||||||
|
|
||||||
|
def load_library(name):
|
||||||
|
if name not in LIBRARIES:
|
||||||
|
sys.exit(f"Unknown library '{name}'. Options: {', '.join(LIBRARIES)}")
|
||||||
|
return importlib.import_module(LIBRARIES[name])
|
||||||
|
|
||||||
CANVAS = 96 # final sprite size (square)
|
|
||||||
GROUND_MARGIN = 4 # empty rows under the feet
|
|
||||||
|
|
||||||
# Palette keys: draw every part using ONLY these exact colors (plus pass-through colors
|
# Palette keys: draw every part using ONLY these exact colors (plus pass-through colors
|
||||||
# like eye white). They get swapped for logo-derived colors at render time.
|
# like eye white). They get swapped for logo-derived colors at render time.
|
||||||
@@ -50,6 +65,18 @@ KEYS = {
|
|||||||
PUPIL = (20, 20, 20) # near-black that is NOT recolored
|
PUPIL = (20, 20, 20) # near-black that is NOT recolored
|
||||||
EYE_WHITE = (255, 255, 255)
|
EYE_WHITE = (255, 255, 255)
|
||||||
|
|
||||||
|
# Fixed-color ramps (light, base, dark) for elemental features that keep their own colors
|
||||||
|
# whatever the brand: fire stays yellow/orange/red, ice stays icy. Like eye white they are
|
||||||
|
# never recolored; parts that use them are type-locked in the library registry.
|
||||||
|
FIXED = {
|
||||||
|
"fire": ((255, 230, 107), (255, 159, 46), (226, 70, 43)),
|
||||||
|
"bluefire": ((214, 246, 255), (92, 200, 255), (47, 107, 232)),
|
||||||
|
"ice": ((242, 252, 255), (168, 230, 247), (90, 169, 214)),
|
||||||
|
"electric": ((255, 246, 168), (255, 217, 59), (224, 162, 26)),
|
||||||
|
"ghostfire": ((240, 210, 255), (185, 123, 240), (116, 69, 184)),
|
||||||
|
}
|
||||||
|
FIXED_COLORS = {c for ramp in FIXED.values() for c in ramp}
|
||||||
|
|
||||||
# Anchor marker pixels. Place one pixel of the color where a child part attaches.
|
# Anchor marker pixels. Place one pixel of the color where a child part attaches.
|
||||||
# They are read, then replaced with the surrounding color.
|
# They are read, then replaced with the surrounding color.
|
||||||
MARKERS = {
|
MARKERS = {
|
||||||
@@ -90,9 +117,9 @@ def part_weight(p, ptype):
|
|||||||
return p["types"].get(ptype, p["other"])
|
return p["types"].get(ptype, p["other"])
|
||||||
|
|
||||||
|
|
||||||
def candidates(slot, plan, ptype, tags, excluded, denied=()):
|
def candidates(lib, slot, plan, ptype, tags, excluded, denied=()):
|
||||||
out = []
|
out = []
|
||||||
for p in cfg.SLOTS[slot]:
|
for p in lib.SLOTS[slot]:
|
||||||
if plan not in p["plans"] or p["name"] in denied:
|
if plan not in p["plans"] or p["name"] in denied:
|
||||||
continue
|
continue
|
||||||
if p["requires"] and not set(p["requires"]) & tags:
|
if p["requires"] and not set(p["requires"]) & tags:
|
||||||
@@ -103,30 +130,37 @@ def candidates(slot, plan, ptype, tags, excluded, denied=()):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def motif_factor(p, motifs):
|
def motif_factor(lib, p, motifs):
|
||||||
"""Keep one creature's features on a theme: same motif boosted, clashing motifs damped."""
|
"""Keep one creature's features on a theme: same motif boosted, clashing motifs damped."""
|
||||||
m = cfg.MOTIFS.get(p["name"])
|
m = lib.MOTIFS.get(p["name"])
|
||||||
if m is None or not motifs:
|
if m is None or not motifs:
|
||||||
return 1.0
|
return 1.0
|
||||||
if m in motifs:
|
if m in motifs:
|
||||||
return cfg.MOTIF_SAME
|
return lib.MOTIF_SAME
|
||||||
if all(frozenset((m, o)) in cfg.MOTIF_PAIRS for o in motifs):
|
if all(frozenset((m, o)) in lib.MOTIF_PAIRS for o in motifs):
|
||||||
return 1.0
|
return 1.0
|
||||||
return cfg.MOTIF_CLASH
|
return lib.MOTIF_CLASH
|
||||||
|
|
||||||
|
|
||||||
def pick_parts(ptype, rng):
|
def pick_parts(lib, ptype, rng):
|
||||||
plan = weighted_pick(rng, cfg.PLAN_WEIGHTS[ptype].items())
|
"""Seeded pick of body plan, body, feature parts and markings. A library may define
|
||||||
|
fits(body, picks) to drop candidates that would push the composite past its canvas."""
|
||||||
|
plan = weighted_pick(rng, lib.PLAN_WEIGHTS[ptype].items())
|
||||||
chosen, tags, excluded, denied, motifs = {}, {f"plan:{plan}"}, set(), set(), set()
|
chosen, tags, excluded, denied, motifs = {}, {f"plan:{plan}"}, set(), set(), set()
|
||||||
features = 0
|
features = 0
|
||||||
|
fits = getattr(lib, "fits", None)
|
||||||
for slot in PICK_ORDER:
|
for slot in PICK_ORDER:
|
||||||
if slot in cfg.DISABLED_SLOTS:
|
if slot in lib.DISABLED_SLOTS:
|
||||||
continue
|
continue
|
||||||
cands = candidates(slot, plan, ptype, tags, excluded, denied)
|
cands = candidates(lib, slot, plan, ptype, tags, excluded, denied)
|
||||||
if slot in cfg.FEATURE_SLOTS:
|
if slot in lib.FEATURE_SLOTS:
|
||||||
if features >= cfg.MAX_FEATURES:
|
if features >= lib.MAX_FEATURES:
|
||||||
cands = [(p, w) for p, w in cands if p["name"] == "none"]
|
cands = [(p, w) for p, w in cands if p["name"] == "none"]
|
||||||
cands = [(p, w * motif_factor(p, motifs)) for p, w in cands]
|
cands = [(p, w * motif_factor(lib, p, motifs)) for p, w in cands]
|
||||||
|
if fits:
|
||||||
|
picked = {s: q for s, q in chosen.items() if s != "body"}
|
||||||
|
cands = [(p, w) for p, w in cands
|
||||||
|
if p["name"] == "none" or fits(chosen["body"], {**picked, slot: p})]
|
||||||
p = weighted_pick(rng, cands)
|
p = weighted_pick(rng, cands)
|
||||||
if p is None:
|
if p is None:
|
||||||
continue
|
continue
|
||||||
@@ -134,11 +168,11 @@ def pick_parts(ptype, rng):
|
|||||||
tags |= set(p["tags"])
|
tags |= set(p["tags"])
|
||||||
excluded |= set(p["excludes"])
|
excluded |= set(p["excludes"])
|
||||||
denied |= set(p.get("deny", ()))
|
denied |= set(p.get("deny", ()))
|
||||||
if p["name"] != "none" and slot in cfg.FEATURE_SLOTS:
|
if p["name"] != "none" and slot in lib.FEATURE_SLOTS:
|
||||||
features += 1
|
features += 1
|
||||||
if cfg.MOTIFS.get(p["name"]):
|
if lib.MOTIFS.get(p["name"]):
|
||||||
motifs.add(cfg.MOTIFS[p["name"]])
|
motifs.add(lib.MOTIFS[p["name"]])
|
||||||
markings = weighted_pick(rng, [(m, w.get(ptype, w.get("_other", 0))) for m, w in cfg.MARKINGS.items()])
|
markings = weighted_pick(rng, [(m, w.get(ptype, w.get("_other", 0))) for m, w in lib.MARKINGS.items()])
|
||||||
return plan, chosen, markings, tags
|
return plan, chosen, markings, tags
|
||||||
|
|
||||||
|
|
||||||
@@ -281,13 +315,13 @@ def compose(parts_dir, chosen, markings, rng):
|
|||||||
return canvas.crop(canvas.getbbox())
|
return canvas.crop(canvas.getbbox())
|
||||||
|
|
||||||
|
|
||||||
def place_on_canvas(sprite):
|
def place_on_canvas(sprite, lib):
|
||||||
w, h = sprite.size
|
w, h = sprite.size
|
||||||
size = max(CANVAS, w + 2, h + GROUND_MARGIN + 1)
|
size = max(lib.CANVAS, w + 2, h + lib.GROUND_MARGIN + 1)
|
||||||
if size > CANVAS:
|
if size > lib.CANVAS:
|
||||||
warn(f"sprite is {w}x{h}, canvas expanded to {size}px")
|
warn(f"sprite is {w}x{h}, canvas expanded to {size}px")
|
||||||
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
out.alpha_composite(sprite, ((size - w) // 2, size - GROUND_MARGIN - h))
|
out.alpha_composite(sprite, ((size - w) // 2, size - lib.GROUND_MARGIN - h))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -445,7 +479,7 @@ def extract_logo_colors(path, n=6):
|
|||||||
|
|
||||||
|
|
||||||
def pick_main_accent(colors, ptype):
|
def pick_main_accent(colors, ptype):
|
||||||
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[ptype])
|
type_rgb = hex_to_rgb(TYPE_COLORS[ptype])
|
||||||
if not colors:
|
if not colors:
|
||||||
main = type_rgb
|
main = type_rgb
|
||||||
accent = rotate_hue(type_rgb, 0.5)
|
accent = rotate_hue(type_rgb, 0.5)
|
||||||
@@ -469,7 +503,7 @@ def pick_main_accent(colors, ptype):
|
|||||||
|
|
||||||
|
|
||||||
def build_palette(main, accent, ptype):
|
def build_palette(main, accent, ptype):
|
||||||
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[ptype])
|
type_rgb = hex_to_rgb(TYPE_COLORS[ptype])
|
||||||
# Elemental parts (leaves, flames, sparks) must read against the body: if the type colour
|
# Elemental parts (leaves, flames, sparks) must read against the body: if the type colour
|
||||||
# is close to the main colour, push its lightness away from the main colour.
|
# is close to the main colour, push its lightness away from the main colour.
|
||||||
if color_dist(type_rgb, main) < 150:
|
if color_dist(type_rgb, main) < 150:
|
||||||
@@ -499,17 +533,23 @@ def classify(description):
|
|||||||
return ans["choice"].lower(), ans.get("probabilities")
|
return ans["choice"].lower(), ans.get("probabilities")
|
||||||
|
|
||||||
|
|
||||||
def generate(name, parts_dir, out_dir, ptype=None, description=None, logo=None, colors=None,
|
def resolve_type(name, ptype=None, description=None):
|
||||||
reroll=0, scale=4):
|
"""(type, probabilities). Jev classifies when no type is given."""
|
||||||
probs = None
|
probs = None
|
||||||
if not ptype:
|
if not ptype:
|
||||||
ptype, probs = classify(description or name)
|
ptype, probs = classify(description or name)
|
||||||
ptype = ptype.lower()
|
ptype = ptype.lower()
|
||||||
if ptype not in cfg.PLAN_WEIGHTS:
|
if ptype not in TYPES:
|
||||||
sys.exit(f"Unknown type '{ptype}'. Options: {', '.join(cfg.TYPES)}")
|
sys.exit(f"Unknown type '{ptype}'. Options: {', '.join(TYPES)}")
|
||||||
|
return ptype, probs
|
||||||
|
|
||||||
|
|
||||||
|
def generate(lib, name, out_dir, ptype, probs=None, logo=None, colors=None, reroll=0, scale=4,
|
||||||
|
parts_dir=None, suffix=""):
|
||||||
|
"""Render one sprite with library `lib`; writes <slug><suffix>.png/.json (+ upscaled copy).
|
||||||
|
Returns (info, sprite)."""
|
||||||
rng = make_rng(name, reroll)
|
rng = make_rng(name, reroll)
|
||||||
plan, chosen, markings, tags = pick_parts(ptype, rng)
|
plan, chosen, markings, tags = pick_parts(lib, ptype, rng)
|
||||||
|
|
||||||
if colors:
|
if colors:
|
||||||
logo_colors = [(hex_to_rgb(c), 1.0) for c in colors]
|
logo_colors = [(hex_to_rgb(c), 1.0) for c in colors]
|
||||||
@@ -522,26 +562,53 @@ def generate(name, parts_dir, out_dir, ptype=None, description=None, logo=None,
|
|||||||
main, accent = pick_main_accent(logo_colors, ptype)
|
main, accent = pick_main_accent(logo_colors, ptype)
|
||||||
palette = build_palette(main, accent, ptype)
|
palette = build_palette(main, accent, ptype)
|
||||||
|
|
||||||
sprite = recolor(place_on_canvas(compose(parts_dir, chosen, markings, rng)), palette)
|
sprite = recolor(place_on_canvas(compose(parts_dir or lib.PARTS_DIR, chosen, markings, rng), lib), palette)
|
||||||
|
|
||||||
out_dir = Path(out_dir)
|
out_dir = Path(out_dir)
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
slug = slugify(name) + (f"-r{reroll}" if reroll else "")
|
slug = slugify(name) + (f"-r{reroll}" if reroll else "") + suffix
|
||||||
sprite.save(out_dir / f"{slug}.png")
|
sprite.save(out_dir / f"{slug}.png")
|
||||||
if scale > 1:
|
if scale > 1:
|
||||||
sprite.resize((sprite.width * scale, sprite.height * scale), Image.NEAREST).save(out_dir / f"{slug}@{scale}x.png")
|
sprite.resize((sprite.width * scale, sprite.height * scale), Image.NEAREST).save(out_dir / f"{slug}@{scale}x.png")
|
||||||
|
|
||||||
info = {
|
info = {
|
||||||
"name": name, "type": ptype, "type_probabilities": probs, "plan": plan,
|
"name": name, "library": lib.NAME, "type": ptype, "type_probabilities": probs, "plan": plan,
|
||||||
"parts": {s: p["name"] for s, p in chosen.items()}, "markings": markings,
|
"parts": {s: p["name"] for s, p in chosen.items()}, "markings": markings,
|
||||||
"attributes": attributes_from(tags),
|
"attributes": attributes_from(tags),
|
||||||
"colors": {"main": rgb_to_hex(main), "accent": rgb_to_hex(accent),
|
"colors": {"main": rgb_to_hex(main), "accent": rgb_to_hex(accent),
|
||||||
"type": cfg.TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])},
|
"type": TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])},
|
||||||
}
|
}
|
||||||
(out_dir / f"{slug}.json").write_text(json.dumps(info, indent=2))
|
(out_dir / f"{slug}.json").write_text(json.dumps(info, indent=2))
|
||||||
print(f"{name}: {ptype.upper()} {plan} | {', '.join(info['attributes']) or 'no attributes'} | "
|
print(f"{name} [{lib.NAME}]: {ptype.upper()} {plan} | {', '.join(info['attributes']) or 'no attributes'} | "
|
||||||
f"main {info['colors']['main']} accent {info['colors']['accent']} -> {out_dir / slug}.png")
|
f"main {info['colors']['main']} accent {info['colors']['accent']} -> {out_dir / slug}.png")
|
||||||
return info
|
return info, sprite
|
||||||
|
|
||||||
|
|
||||||
|
COMPARE_SIZE = 384 # each sprite is scaled by a whole factor to this height in the comparison
|
||||||
|
|
||||||
|
|
||||||
|
def write_compare(path, title, rendered):
|
||||||
|
"""Side-by-side sheet. rendered: [(info, sprite)], one per library."""
|
||||||
|
pad, label = 16, 28
|
||||||
|
cells = []
|
||||||
|
for info, spr in rendered:
|
||||||
|
k = max(1, COMPARE_SIZE // spr.height)
|
||||||
|
big = spr.resize((spr.width * k, spr.height * k), Image.NEAREST)
|
||||||
|
cells.append((f"{info['library']} ({spr.width}x{spr.height}): {info['plan']}, "
|
||||||
|
f"{', '.join(n for s, n in info['parts'].items() if s != 'body' and n != 'none') or 'no extras'}",
|
||||||
|
big))
|
||||||
|
w = sum(c.width for _, c in cells) + pad * (len(cells) + 1)
|
||||||
|
h = max(c.height for _, c in cells) + pad * 2 + label * 2
|
||||||
|
sheet = Image.new("RGBA", (w, h), (232, 232, 226, 255))
|
||||||
|
d = ImageDraw.Draw(sheet)
|
||||||
|
d.text((pad, pad // 2), title, fill=(30, 30, 30, 255))
|
||||||
|
x = pad
|
||||||
|
for text, big in cells:
|
||||||
|
d.text((x, pad // 2 + label), text, fill=(60, 60, 60, 255))
|
||||||
|
sheet.alpha_composite(big, (x, pad + label * 2))
|
||||||
|
x += big.width + pad
|
||||||
|
sheet.save(path)
|
||||||
|
print(f"comparison -> {path}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================ placeholders
|
# ============================================================ placeholders
|
||||||
@@ -603,9 +670,9 @@ def draw_placeholder(slot, p):
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def make_placeholders(parts_dir, overwrite=False):
|
def make_placeholders(lib, parts_dir, overwrite=False):
|
||||||
made = 0
|
made = 0
|
||||||
for slot, parts in cfg.SLOTS.items():
|
for slot, parts in lib.SLOTS.items():
|
||||||
(Path(parts_dir) / slot).mkdir(parents=True, exist_ok=True)
|
(Path(parts_dir) / slot).mkdir(parents=True, exist_ok=True)
|
||||||
for p in parts:
|
for p in parts:
|
||||||
if p["name"] == "none":
|
if p["name"] == "none":
|
||||||
@@ -620,10 +687,10 @@ def make_placeholders(parts_dir, overwrite=False):
|
|||||||
|
|
||||||
# ============================================================ sprite list + weight report
|
# ============================================================ sprite list + weight report
|
||||||
|
|
||||||
def simulate(ptype, n=3000):
|
def simulate(lib, ptype, n=3000):
|
||||||
plans, slots, marks = Counter(), {s: Counter() for s in PICK_ORDER}, Counter()
|
plans, slots, marks = Counter(), {s: Counter() for s in PICK_ORDER}, Counter()
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
plan, chosen, m, _ = pick_parts(ptype, random.Random(i))
|
plan, chosen, m, _ = pick_parts(lib, ptype, random.Random(i))
|
||||||
plans[plan] += 1
|
plans[plan] += 1
|
||||||
marks[m] += 1
|
marks[m] += 1
|
||||||
for s in PICK_ORDER:
|
for s in PICK_ORDER:
|
||||||
@@ -635,20 +702,23 @@ def pct(counter, n, top=None):
|
|||||||
return ", ".join(f"{k} {v / n:.0%}" for k, v in counter.most_common(top) if v / n >= 0.01)
|
return ", ".join(f"{k} {v / n:.0%}" for k, v in counter.most_common(top) if v / n >= 0.01)
|
||||||
|
|
||||||
|
|
||||||
def write_sprite_list(path):
|
def write_sprite_list(lib, path):
|
||||||
L = ["# Sprite checklist", ""]
|
L = [f"# Sprite checklist ({lib.NAME} library)", ""]
|
||||||
total = sum(1 for parts in cfg.SLOTS.values() for p in parts if p["name"] != "none")
|
total = sum(1 for parts in lib.SLOTS.values() for p in parts if p["name"] != "none")
|
||||||
L += [f"**{total} sprites** across {len(cfg.SLOTS)} slots. Save each as `parts/<slot>/<name>.png`.", "",
|
L += [f"**{total} sprites** across {len(lib.SLOTS)} slots. Save each as `{lib.PARTS_DIR}/<slot>/<name>.png`.", "",
|
||||||
"## Art rules", "",
|
"## Art rules", "",
|
||||||
f"- Final canvas is {CANVAS}x{CANVAS}. Sizes below are suggested part canvases; crop tight with transparency.",
|
f"- Final canvas is {lib.CANVAS}x{lib.CANVAS}. Sizes below are suggested part canvases; crop tight with transparency.",
|
||||||
"- Characters face left in a 3/4 view, light from the top-left. 1px outline, no anti-aliasing, no semi-transparent pixels.",
|
"- Characters face left in a 3/4 view, light from the top-left. 1px outline, no anti-aliasing, no semi-transparent pixels.",
|
||||||
"- Draw only with the palette keys below. Any other color (eye white, pupils) is left as-is.", ""]
|
"- Draw only with the palette keys below. Any other color (eye white, pupils, fixed ramps) is left as-is.", ""]
|
||||||
L += ["| Key | Hex | Use |", "|---|---|---|"]
|
L += ["| Key | Hex | Use |", "|---|---|---|"]
|
||||||
uses = {"main": "body / most fur and skin", "accent": "horns, wings, beaks, crests, shells",
|
uses = {"main": "body / most fur and skin", "accent": "horns, wings, beaks, crests, shells",
|
||||||
"type": "elemental bits: flames, leaves, sparks, ice, water"}
|
"type": "elemental bits: flames, leaves, sparks, ice, water"}
|
||||||
for k, v in KEYS.items():
|
for k, v in KEYS.items():
|
||||||
L.append(f"| {k} | {rgb_to_hex(v)} | {'outline (recolored)' if k == 'outline' else uses[k.split('_')[0]]} |")
|
L.append(f"| {k} | {rgb_to_hex(v)} | {'outline (recolored)' if k == 'outline' else uses[k.split('_')[0]]} |")
|
||||||
L += [f"| pupil | {rgb_to_hex(PUPIL)} | not recolored |", f"| eye white | {rgb_to_hex(EYE_WHITE)} | not recolored |", ""]
|
L += [f"| pupil | {rgb_to_hex(PUPIL)} | not recolored |", f"| eye white | {rgb_to_hex(EYE_WHITE)} | not recolored |"]
|
||||||
|
for name, shades in FIXED.items():
|
||||||
|
L.append(f"| {name} (light, base, dark) | {', '.join(rgb_to_hex(c) for c in shades)} | fixed color, not recolored |")
|
||||||
|
L.append("")
|
||||||
L += ["### Anchor marker pixels", "",
|
L += ["### Anchor marker pixels", "",
|
||||||
"One pixel each. `pivot` is where the part attaches to its parent; the others are where children attach.", "",
|
"One pixel each. `pivot` is where the part attaches to its parent; the others are where children attach.", "",
|
||||||
"| Marker | Hex | Needed in |", "|---|---|---|",
|
"| Marker | Hex | Needed in |", "|---|---|---|",
|
||||||
@@ -663,18 +733,18 @@ def write_sprite_list(path):
|
|||||||
L += [f"## {slot} ({len(parts)})", "", "| Done | File | Size | Palette | Plans | Description | Most likely for |",
|
L += [f"## {slot} ({len(parts)})", "", "| Done | File | Size | Palette | Plans | Description | Most likely for |",
|
||||||
"|---|---|---|---|---|---|---|"]
|
"|---|---|---|---|---|---|---|"]
|
||||||
for p in parts:
|
for p in parts:
|
||||||
top = sorted(cfg.TYPES, key=lambda t: -part_weight(p, t))[:3]
|
top = sorted(TYPES, key=lambda t: -part_weight(p, t))[:3]
|
||||||
top = ", ".join(t for t in top if part_weight(p, t) > 0)
|
top = ", ".join(t for t in top if part_weight(p, t) > 0)
|
||||||
plans = "all" if p["plans"] == cfg.ALL else ", ".join(p["plans"])
|
plans = "all" if set(p["plans"]) == set(lib.ALL) else ", ".join(p["plans"])
|
||||||
w, h = size_for(slot, p)
|
w, h = size_for(slot, p)
|
||||||
L.append(f"| [ ] | `{slot}/{p['name']}.png` | {w}x{h} | {p['palette']} | {plans} | {p['desc']} | {top} |")
|
L.append(f"| [ ] | `{slot}/{p['name']}.png` | {w}x{h} | {p['palette']} | {plans} | {p['desc']} | {top} |")
|
||||||
L.append("")
|
L.append("")
|
||||||
|
|
||||||
L += ["## Simulated pick rates per type", "",
|
L += ["## Simulated pick rates per type", "",
|
||||||
"From 3,000 random seeds per type. Use this to spot parts that never show up or show up too much.", ""]
|
"From 3,000 random seeds per type. Use this to spot parts that never show up or show up too much.", ""]
|
||||||
for t in cfg.TYPES:
|
for t in TYPES:
|
||||||
n = 3000
|
n = 3000
|
||||||
plans, slots, marks = simulate(t, n)
|
plans, slots, marks = simulate(lib, t, n)
|
||||||
L += [f"### {t.capitalize()}", "", f"- **Body plan:** {pct(plans, n)}"]
|
L += [f"### {t.capitalize()}", "", f"- **Body plan:** {pct(plans, n)}"]
|
||||||
for s in PICK_ORDER:
|
for s in PICK_ORDER:
|
||||||
L.append(f"- **{s}:** {pct(slots[s], n, 8)}")
|
L.append(f"- **{s}:** {pct(slots[s], n, 8)}")
|
||||||
@@ -685,6 +755,18 @@ def write_sprite_list(path):
|
|||||||
|
|
||||||
# ============================================================ CLI
|
# ============================================================ CLI
|
||||||
|
|
||||||
|
def run_one(a, name, ptype=None, description=None, logo=None, colors=None, reroll=0):
|
||||||
|
"""Render `name` with --library, or with every library plus a comparison sheet (--compare)."""
|
||||||
|
ptype, probs = resolve_type(name, ptype, description)
|
||||||
|
if not a.compare:
|
||||||
|
generate(load_library(a.library), name, a.out, ptype, probs, logo, colors, reroll, a.scale, a.parts)
|
||||||
|
return
|
||||||
|
rendered = [generate(load_library(n), name, a.out, ptype, probs, logo, colors, reroll, a.scale, suffix=f"-{n}")
|
||||||
|
for n in LIBRARIES]
|
||||||
|
slug = slugify(name) + (f"-r{reroll}" if reroll else "")
|
||||||
|
write_compare(Path(a.out) / f"{slug}-compare.png", f"{name} | type: {ptype}", rendered)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="Generate Pokémon-style business sprites")
|
ap = argparse.ArgumentParser(description="Generate Pokémon-style business sprites")
|
||||||
ap.add_argument("name", nargs="?", help="Business name (also the random seed)")
|
ap.add_argument("name", nargs="?", help="Business name (also the random seed)")
|
||||||
@@ -694,7 +776,11 @@ def main():
|
|||||||
ap.add_argument("--logo", help="Logo image to pull colors from")
|
ap.add_argument("--logo", help="Logo image to pull colors from")
|
||||||
ap.add_argument("--colors", help='Manual colors, e.g. "#2E7D32,#FBC02D" (main first)')
|
ap.add_argument("--colors", help='Manual colors, e.g. "#2E7D32,#FBC02D" (main first)')
|
||||||
ap.add_argument("--reroll", type=int, default=0, help="Alternate creature for the same business")
|
ap.add_argument("--reroll", type=int, default=0, help="Alternate creature for the same business")
|
||||||
ap.add_argument("--parts", default="parts", help="Parts folder")
|
ap.add_argument("--library", choices=list(LIBRARIES), default=DEFAULT_LIBRARY,
|
||||||
|
help=f"Part library (default {DEFAULT_LIBRARY})")
|
||||||
|
ap.add_argument("--compare", action="store_true",
|
||||||
|
help="Render with every library: <slug>-<library>.png plus <slug>-compare.png side by side")
|
||||||
|
ap.add_argument("--parts", help="Parts folder (default: the library's own)")
|
||||||
ap.add_argument("--out", default="sprites", help="Output folder")
|
ap.add_argument("--out", default="sprites", help="Output folder")
|
||||||
ap.add_argument("--scale", type=int, default=4, help="Also save an upscaled copy (1 to disable)")
|
ap.add_argument("--scale", type=int, default=4, help="Also save an upscaled copy (1 to disable)")
|
||||||
ap.add_argument("--batch", help="CSV with columns name,logo,type,description (only name required)")
|
ap.add_argument("--batch", help="CSV with columns name,logo,type,description (only name required)")
|
||||||
@@ -702,25 +788,27 @@ def main():
|
|||||||
ap.add_argument("--overwrite", action="store_true", help="With --make-placeholders, replace existing files")
|
ap.add_argument("--overwrite", action="store_true", help="With --make-placeholders, replace existing files")
|
||||||
ap.add_argument("--sprite-list", metavar="PATH", help="Write the art checklist and weight report")
|
ap.add_argument("--sprite-list", metavar="PATH", help="Write the art checklist and weight report")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
if a.compare and a.parts:
|
||||||
|
ap.error("--parts picks one library's folder; it can't be combined with --compare")
|
||||||
|
|
||||||
|
lib = load_library(a.library)
|
||||||
if a.make_placeholders:
|
if a.make_placeholders:
|
||||||
make_placeholders(a.parts, a.overwrite)
|
make_placeholders(lib, a.parts or lib.PARTS_DIR, a.overwrite)
|
||||||
if a.sprite_list:
|
if a.sprite_list:
|
||||||
write_sprite_list(a.sprite_list)
|
write_sprite_list(lib, a.sprite_list)
|
||||||
colors = a.colors.split(",") if a.colors else None
|
colors = a.colors.split(",") if a.colors else None
|
||||||
if a.batch:
|
if a.batch:
|
||||||
with open(a.batch, newline="", encoding="utf-8") as f:
|
with open(a.batch, newline="", encoding="utf-8") as f:
|
||||||
for row in csv.DictReader(f):
|
for row in csv.DictReader(f):
|
||||||
if not (row.get("name") or "").strip():
|
if not (row.get("name") or "").strip():
|
||||||
continue
|
continue
|
||||||
generate(row["name"].strip(), a.parts, a.out, ptype=(row.get("type") or "").strip() or None,
|
run_one(a, row["name"].strip(), (row.get("type") or "").strip() or None,
|
||||||
description=(row.get("description") or "").strip() or None,
|
(row.get("description") or "").strip() or None, (row.get("logo") or "").strip() or None)
|
||||||
logo=(row.get("logo") or "").strip() or None, scale=a.scale)
|
|
||||||
elif a.name:
|
elif a.name:
|
||||||
logo = a.logo or a.logo_file
|
logo = a.logo or a.logo_file
|
||||||
if logo and not Path(logo).exists():
|
if logo and not Path(logo).exists():
|
||||||
sys.exit(f"Logo not found: {logo}")
|
sys.exit(f"Logo not found: {logo}")
|
||||||
generate(a.name, a.parts, a.out, a.type, a.desc, logo, colors, a.reroll, a.scale)
|
run_one(a, a.name, a.type, a.desc, logo, colors, a.reroll)
|
||||||
elif not (a.make_placeholders or a.sprite_list):
|
elif not (a.make_placeholders or a.sprite_list):
|
||||||
ap.print_help()
|
ap.print_help()
|
||||||
|
|
||||||
|
|||||||
BIN
parts_pl/back/pl_barnacles.png
Normal file
|
After Width: | Height: | Size: 262 B |
BIN
parts_pl/back/pl_beetle_rock_spikes.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
parts_pl/back/pl_brawler_shell.png
Normal file
|
After Width: | Height: | Size: 472 B |
BIN
parts_pl/back/pl_bubbles_toxic.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
parts_pl/back/pl_cape_frost.png
Normal file
|
After Width: | Height: | Size: 513 B |
BIN
parts_pl/back/pl_cape_short.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
parts_pl/back/pl_cloud_puffs.png
Normal file
|
After Width: | Height: | Size: 371 B |
BIN
parts_pl/back/pl_coils_electric.png
Normal file
|
After Width: | Height: | Size: 498 B |
BIN
parts_pl/back/pl_coral_back.png
Normal file
|
After Width: | Height: | Size: 212 B |
BIN
parts_pl/back/pl_crystal_cluster.png
Normal file
|
After Width: | Height: | Size: 499 B |
BIN
parts_pl/back/pl_dino_crystals_back.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
parts_pl/back/pl_drake_crystals.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
parts_pl/back/pl_fin_dorsal.png
Normal file
|
After Width: | Height: | Size: 161 B |
BIN
parts_pl/back/pl_flower_back.png
Normal file
|
After Width: | Height: | Size: 586 B |
BIN
parts_pl/back/pl_icicles_back.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
parts_pl/back/pl_leaf_cape.png
Normal file
|
After Width: | Height: | Size: 495 B |
BIN
parts_pl/back/pl_mane_bristle.png
Normal file
|
After Width: | Height: | Size: 278 B |
BIN
parts_pl/back/pl_mane_fire.png
Normal file
|
After Width: | Height: | Size: 737 B |
BIN
parts_pl/back/pl_mane_flame.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
parts_pl/back/pl_mushrooms_back.png
Normal file
|
After Width: | Height: | Size: 254 B |
BIN
parts_pl/back/pl_pearl_orb.png
Normal file
|
After Width: | Height: | Size: 141 B |
BIN
parts_pl/back/pl_plates_rock.png
Normal file
|
After Width: | Height: | Size: 333 B |
BIN
parts_pl/back/pl_rocks_dirt.png
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
parts_pl/back/pl_saddle_rock.png
Normal file
|
After Width: | Height: | Size: 249 B |
BIN
parts_pl/back/pl_sail_fin.png
Normal file
|
After Width: | Height: | Size: 234 B |
BIN
parts_pl/back/pl_scarf_long.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
parts_pl/back/pl_seal_shell.png
Normal file
|
After Width: | Height: | Size: 140 B |
BIN
parts_pl/back/pl_shell_spiral.png
Normal file
|
After Width: | Height: | Size: 489 B |
BIN
parts_pl/back/pl_shroud_ghostfire.png
Normal file
|
After Width: | Height: | Size: 631 B |
BIN
parts_pl/back/pl_slime_spikes_poison.png
Normal file
|
After Width: | Height: | Size: 593 B |
BIN
parts_pl/back/pl_spikes_back.png
Normal file
|
After Width: | Height: | Size: 277 B |
BIN
parts_pl/back/pl_spikes_frill.png
Normal file
|
After Width: | Height: | Size: 303 B |
BIN
parts_pl/back/pl_spines_toxic.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
parts_pl/back/pl_wings_bat.png
Normal file
|
After Width: | Height: | Size: 268 B |
BIN
parts_pl/back/pl_wings_bug.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
parts_pl/back/pl_wings_dragon.png
Normal file
|
After Width: | Height: | Size: 321 B |
BIN
parts_pl/back/pl_wings_elytra.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
parts_pl/back/pl_wings_feather.png
Normal file
|
After Width: | Height: | Size: 779 B |
BIN
parts_pl/back/pl_wings_gold.png
Normal file
|
After Width: | Height: | Size: 340 B |
BIN
parts_pl/back/pl_wings_storm.png
Normal file
|
After Width: | Height: | Size: 437 B |
BIN
parts_pl/back/pl_wings_tiny.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
parts_pl/back/pl_wisp_orbs_bluefire.png
Normal file
|
After Width: | Height: | Size: 290 B |
BIN
parts_pl/body/pl_axolotl.png
Normal file
|
After Width: | Height: | Size: 807 B |
BIN
parts_pl/body/pl_bee.png
Normal file
|
After Width: | Height: | Size: 736 B |
BIN
parts_pl/body/pl_beetle.png
Normal file
|
After Width: | Height: | Size: 807 B |
BIN
parts_pl/body/pl_boar.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
parts_pl/body/pl_brawler.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
parts_pl/body/pl_bunny.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
parts_pl/body/pl_chick.png
Normal file
|
After Width: | Height: | Size: 631 B |
BIN
parts_pl/body/pl_crab.png
Normal file
|
After Width: | Height: | Size: 756 B |
BIN
parts_pl/body/pl_cub.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
parts_pl/body/pl_dino.png
Normal file
|
After Width: | Height: | Size: 793 B |
BIN
parts_pl/body/pl_drake.png
Normal file
|
After Width: | Height: | Size: 740 B |
BIN
parts_pl/body/pl_duck.png
Normal file
|
After Width: | Height: | Size: 707 B |
BIN
parts_pl/body/pl_eel.png
Normal file
|
After Width: | Height: | Size: 602 B |
BIN
parts_pl/body/pl_fawn.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
parts_pl/body/pl_fox.png
Normal file
|
After Width: | Height: | Size: 869 B |
BIN
parts_pl/body/pl_grub.png
Normal file
|
After Width: | Height: | Size: 812 B |
BIN
parts_pl/body/pl_guppy.png
Normal file
|
After Width: | Height: | Size: 595 B |
BIN
parts_pl/body/pl_hawk.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
parts_pl/body/pl_imp.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
parts_pl/body/pl_lizard.png
Normal file
|
After Width: | Height: | Size: 634 B |
BIN
parts_pl/body/pl_mochi.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
parts_pl/body/pl_monk.png
Normal file
|
After Width: | Height: | Size: 621 B |
BIN
parts_pl/body/pl_owl.png
Normal file
|
After Width: | Height: | Size: 748 B |
BIN
parts_pl/body/pl_pebble.png
Normal file
|
After Width: | Height: | Size: 742 B |
BIN
parts_pl/body/pl_seal.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
parts_pl/body/pl_slime.png
Normal file
|
After Width: | Height: | Size: 590 B |
BIN
parts_pl/body/pl_snake.png
Normal file
|
After Width: | Height: | Size: 890 B |
BIN
parts_pl/body/pl_sprite.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
parts_pl/body/pl_wisp.png
Normal file
|
After Width: | Height: | Size: 724 B |
BIN
parts_pl/body/pl_worm.png
Normal file
|
After Width: | Height: | Size: 753 B |
BIN
parts_pl/body/pl_wyrm.png
Normal file
|
After Width: | Height: | Size: 783 B |
BIN
parts_pl/head_top/pl_antennae.png
Normal file
|
After Width: | Height: | Size: 209 B |
BIN
parts_pl/head_top/pl_antennae_spark.png
Normal file
|
After Width: | Height: | Size: 359 B |
BIN
parts_pl/head_top/pl_antlers_leafy.png
Normal file
|
After Width: | Height: | Size: 255 B |
BIN
parts_pl/head_top/pl_axolotl_sprout.png
Normal file
|
After Width: | Height: | Size: 179 B |
BIN
parts_pl/head_top/pl_bunny_flower_crown.png
Normal file
|
After Width: | Height: | Size: 247 B |
BIN
parts_pl/head_top/pl_coral_crown.png
Normal file
|
After Width: | Height: | Size: 247 B |
BIN
parts_pl/head_top/pl_crest_electric.png
Normal file
|
After Width: | Height: | Size: 387 B |
BIN
parts_pl/head_top/pl_crest_feather.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
parts_pl/head_top/pl_crest_flame.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
parts_pl/head_top/pl_crown_bluefire.png
Normal file
|
After Width: | Height: | Size: 224 B |
BIN
parts_pl/head_top/pl_crown_ice.png
Normal file
|
After Width: | Height: | Size: 417 B |
BIN
parts_pl/head_top/pl_crystals_head.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
parts_pl/head_top/pl_ears_bolt.png
Normal file
|
After Width: | Height: | Size: 309 B |
BIN
parts_pl/head_top/pl_ears_flame.png
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
parts_pl/head_top/pl_ears_long.png
Normal file
|
After Width: | Height: | Size: 252 B |
BIN
parts_pl/head_top/pl_fins_head.png
Normal file
|
After Width: | Height: | Size: 147 B |
BIN
parts_pl/head_top/pl_flower_bud.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
parts_pl/head_top/pl_gills_frill.png
Normal file
|
After Width: | Height: | Size: 414 B |
BIN
parts_pl/head_top/pl_grub_flower.png
Normal file
|
After Width: | Height: | Size: 221 B |
BIN
parts_pl/head_top/pl_guppy_pearl_crown.png
Normal file
|
After Width: | Height: | Size: 188 B |
BIN
parts_pl/head_top/pl_hawk_headband.png
Normal file
|
After Width: | Height: | Size: 272 B |
BIN
parts_pl/head_top/pl_headband.png
Normal file
|
After Width: | Height: | Size: 370 B |
BIN
parts_pl/head_top/pl_hood_cobra.png
Normal file
|
After Width: | Height: | Size: 402 B |