Bestiary portraits in the high-res style: a painted pixel-art bust for every creature
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -9,3 +9,5 @@ art/generated/creature-previews/
|
||||
art/generated/sprites/*-raw*.png
|
||||
art/generated/sprites/anim/*-raw*.png
|
||||
art/generated/sprites/scripts/__pycache__/
|
||||
art/generated/portraits/*-raw*.png
|
||||
art/generated/portraits/scripts/__pycache__/
|
||||
@@ -0,0 +1,62 @@
|
||||
# Bestiary portraits (fal.ai)
|
||||
|
||||
Generated on 2026-11-07/08: one portrait per creature kind (the 30 of
|
||||
`../sprites/manifest.json` plus the imp, `EK::Worker`) for the bestiary in the high-res style.
|
||||
Bust (head and shoulders; head and front body for beasts, the machine for the siege engines),
|
||||
three-quarter view, torch light from one side, dark dungeon wall behind.
|
||||
|
||||
- `<id>-raw.png`: the model output, 1024x1024 (local only, ignored by git).
|
||||
- `<id>-128.png`: the processed 128x128 pixel-art portrait (48 colours, opaque).
|
||||
- `../../../assets/portraits/portraits.png`: all 31 in one atlas, 8 columns x 4 rows of 128x128,
|
||||
in the order of `../../../assets/portraits/order.txt` (`EK` names; mirrored by `ORDER` in
|
||||
`src/portraits.rs`, a test checks both agree).
|
||||
|
||||
## Generation
|
||||
|
||||
- Model: `fal-ai/nano-banana-pro/edit` (POST https://fal.run/fal-ai/nano-banana-pro/edit)
|
||||
- `aspect_ratio: 1:1`, `resolution: 1K`, `num_images: 1`, `output_format: png`
|
||||
- Reference image (`image_urls`, data URI): the front and front three-quarter view of the
|
||||
creature's idle sheet `../sprites/<id>-64.png` (the imp: `assets/sprites/imp-64.png`),
|
||||
scaled 6x nearest onto a flat dark grey (768x384).
|
||||
- 31 images, no retries (every one showed the right character, no text). About $4.65.
|
||||
- Scripts in `scripts/`: `portraits.py` (descriptions taken from
|
||||
`../sprites/scripts/creatures.py` plus the imp, framing per kind, prompt template), `gen.py`
|
||||
(requests, 4 at a time; reads the key from `~/.config/dungeoncraft/fal-key`), `process.py`.
|
||||
- The first two (knight, spider) were sent before "(the torch itself outside the picture)" was
|
||||
added; they show a torch.
|
||||
|
||||
### Prompt template
|
||||
|
||||
`{thing}` is `character`, `creature` (beasts: spider, bat, owl, wyrm, beetle, cavebear,
|
||||
gargoyle) or `siege engine` (ram, catapult); `{framing}` is `a head-and-shoulders bust
|
||||
portrait`, `a close-up of its head and the front of its body (bust portrait)` or `a close-up of
|
||||
the front part of the machine with its crew-less wooden frame and iron parts`.
|
||||
|
||||
```text
|
||||
The attached image shows a small pixel art game sprite of the "{name}" (left: front view, right: three-quarter view). {desc}
|
||||
|
||||
Paint a PORTRAIT of exactly this {thing} for the illustrated monster compendium of a dark-fantasy dungeon strategy game: {framing}, three-quarter view turned slightly to the side, looking at the viewer, filling most of the square picture. It must clearly be the same {thing} as in the sprite: the same colours, clothing, armour, markings, gear and weapon (show the weapon or tool where it fits in the frame), but drawn with much more detail and slightly more natural, characterful proportions.
|
||||
|
||||
Style: hand-painted pixel art, like a detailed 128x128 pixel portrait scaled up with hard square pixels, a limited colour palette, clean dark outlines, no anti-aliasing blur, no smooth gradients, no photo realism.
|
||||
|
||||
Lighting and background: a little dramatic - a warm torch light from one side (the torch itself outside the picture) and a deep shadow on the other side, a rim light on the silhouette. Dark atmospheric dungeon background: rough stone wall in darkness, a faint glow and drifting dust or mist, darkening to near black at the edges.
|
||||
|
||||
Exactly one {thing}, no text, no letters, no name, no labels, no frame, no border, no signature, no UI.
|
||||
```
|
||||
|
||||
## Notes per creature
|
||||
|
||||
All usable as generated. Weaker in the "close-up" sense: the ram and the catapult show the
|
||||
whole machine rather than a close-up; the giant is a plain bald man with a club (as the sprite).
|
||||
The cave dwarf in the bestiary uses the dwarf miner's portrait (as it uses its sprite).
|
||||
|
||||
## Processing
|
||||
|
||||
```bash
|
||||
/tmp/sprvenv/bin/python -I scripts/gen.py # all, or: gen.py <id> ...
|
||||
/tmp/sprvenv/bin/python -I scripts/process.py
|
||||
```
|
||||
|
||||
`process.py`: whole picture (optional per-id crop fractions in `CROP`), box downscale to
|
||||
128x128, sharpness 1.6, quantize to 48 colours (max coverage, k-means 4, no dithering), RGB;
|
||||
writes `<id>-128.png`, the atlas and `order.txt`.
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
# Generate bestiary portraits with fal-ai/nano-banana-pro/edit; the reference image is the
|
||||
# front and front three-quarter view of the creature's 64 px idle sheet, scaled up 6x.
|
||||
import base64, io, json, os, sys, urllib.request, concurrent.futures as cf
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from portraits import CREATURES, prompt_for, sheet
|
||||
|
||||
OUT = "/home/zink/Projects/dungeoncraft/art/generated/portraits"
|
||||
KEY = open(os.path.expanduser("~/.config/dungeoncraft/fal-key")).read().strip()
|
||||
EXTRA = os.environ.get("EXTRA", "")
|
||||
|
||||
|
||||
def ref_uri(cid):
|
||||
im = Image.open(sheet(cid)).convert("RGBA")
|
||||
h = im.height
|
||||
two = im.crop((0, 0, 2 * h, h)).resize((12 * h, 6 * h), Image.NEAREST)
|
||||
bg = Image.new("RGBA", two.size, (40, 36, 44, 255))
|
||||
bg.alpha_composite(two)
|
||||
buf = io.BytesIO()
|
||||
bg.convert("RGB").save(buf, "PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def gen(cid, suffix=""):
|
||||
body = {"prompt": prompt_for(cid, EXTRA), "image_urls": [ref_uri(cid)], "aspect_ratio": "1:1",
|
||||
"resolution": "1K", "num_images": 1, "output_format": "png"}
|
||||
req = urllib.request.Request("https://fal.run/fal-ai/nano-banana-pro/edit", data=json.dumps(body).encode(),
|
||||
headers={"Authorization": "Key " + KEY, "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
res = json.load(r)
|
||||
with urllib.request.urlopen(res["images"][0]["url"], timeout=300) as r:
|
||||
data = r.read()
|
||||
path = f"{OUT}/{cid}-raw{suffix}.png"
|
||||
open(path, "wb").write(data)
|
||||
return cid, "ok " + path
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if hasattr(e, "read"):
|
||||
try:
|
||||
msg += " " + e.read().decode()[:300]
|
||||
except Exception:
|
||||
pass
|
||||
return cid, "ERROR " + msg
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ids = sys.argv[1:] or [c["id"] for c in CREATURES]
|
||||
suffix = os.environ.get("SUFFIX", "")
|
||||
with cf.ThreadPoolExecutor(4) as ex:
|
||||
for cid, st in ex.map(lambda i: gen(i, suffix), ids):
|
||||
print(cid, st, flush=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Portrait prompts for the bestiary (high-res style). Descriptions come from the sprite pipeline.
|
||||
import importlib.util, os
|
||||
|
||||
ROOT = "/home/zink/Projects/dungeoncraft"
|
||||
_spec = importlib.util.spec_from_file_location("creatures", f"{ROOT}/art/generated/sprites/scripts/creatures.py")
|
||||
_cr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_cr)
|
||||
|
||||
IMP = dict(id="imp", kind="Worker", name="Pit Imp",
|
||||
desc="A small goblin-like worker demon: big head, oversized pointed ears and two small black horns, orange skin, glowing yellow eyes, a wide mischievous grin with tiny fangs, a ragged dark-red tunic with a rope belt, carrying a small iron pickaxe with a wooden handle over its right shoulder.")
|
||||
CREATURES = [IMP] + [dict(id=c["id"], kind=c["kind"], name=c["name"], desc=c["desc"]) for c in _cr.CREATURES]
|
||||
BY_ID = {c["id"]: c for c in CREATURES}
|
||||
SIEGE = {"ram", "catapult"}
|
||||
BEAST = {"spider", "bat", "owl", "wyrm", "beetle", "cavebear", "gargoyle"}
|
||||
|
||||
|
||||
def sheet(cid):
|
||||
if cid == "imp":
|
||||
return f"{ROOT}/assets/sprites/imp-64.png"
|
||||
return f"{ROOT}/art/generated/sprites/{cid}-64.png"
|
||||
|
||||
|
||||
TEMPLATE = """The attached image shows a small pixel art game sprite of the "{name}" (left: front view, right: three-quarter view). {desc}
|
||||
|
||||
Paint a PORTRAIT of exactly this {thing} for the illustrated monster compendium of a dark-fantasy dungeon strategy game: {framing}, three-quarter view turned slightly to the side, looking at the viewer, filling most of the square picture. It must clearly be the same {thing} as in the sprite: the same colours, clothing, armour, markings, gear and weapon (show the weapon or tool where it fits in the frame), but drawn with much more detail and slightly more natural, characterful proportions.
|
||||
|
||||
Style: hand-painted pixel art, like a detailed 128x128 pixel portrait scaled up with hard square pixels, a limited colour palette, clean dark outlines, no anti-aliasing blur, no smooth gradients, no photo realism.
|
||||
|
||||
Lighting and background: a little dramatic - a warm torch light from one side (the torch itself outside the picture) and a deep shadow on the other side, a rim light on the silhouette. Dark atmospheric dungeon background: rough stone wall in darkness, a faint glow and drifting dust or mist, darkening to near black at the edges.
|
||||
|
||||
Exactly one {thing}, no text, no letters, no name, no labels, no frame, no border, no signature, no UI."""
|
||||
|
||||
|
||||
def prompt_for(cid, extra=""):
|
||||
c = BY_ID[cid]
|
||||
if cid in SIEGE:
|
||||
thing, framing = "siege engine", "a close-up of the front part of the machine with its crew-less wooden frame and iron parts"
|
||||
elif cid in BEAST:
|
||||
thing, framing = "creature", "a close-up of its head and the front of its body (bust portrait)"
|
||||
else:
|
||||
thing, framing = "character", "a head-and-shoulders bust portrait"
|
||||
p = TEMPLATE.format(name=c["name"], desc=c["desc"], thing=thing, framing=framing)
|
||||
return p + ("\n\n" + extra if extra else "")
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# Raw portraits (1024x1024) -> 128x128 pixel-art portraits (box downscale, 48 colours, no
|
||||
# dithering, opaque) and the atlas assets/portraits/portraits.png (8 columns) + order.txt.
|
||||
import os, sys
|
||||
from PIL import Image, ImageEnhance
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from portraits import CREATURES
|
||||
|
||||
ROOT = "/home/zink/Projects/dungeoncraft"
|
||||
D = f"{ROOT}/art/generated/portraits"
|
||||
OUT = f"{ROOT}/assets/portraits"
|
||||
SIZE, COLORS, COLS = 128, 48, 8
|
||||
CROP = {} # id -> (l, t, r, b) fractions, default the whole picture
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
rows = (len(CREATURES) + COLS - 1) // COLS
|
||||
atlas = Image.new("RGB", (COLS * SIZE, rows * SIZE), (0, 0, 0))
|
||||
order = []
|
||||
for i, c in enumerate(CREATURES):
|
||||
im = Image.open(f"{D}/{c['id']}-raw.png").convert("RGB")
|
||||
l, t, r, b = CROP.get(c["id"], (0, 0, 1, 1))
|
||||
w, h = im.size
|
||||
im = im.crop((round(l * w), round(t * h), round(r * w), round(b * h)))
|
||||
im = im.resize((SIZE, SIZE), Image.BOX)
|
||||
im = ImageEnhance.Sharpness(im).enhance(1.6)
|
||||
im = im.quantize(colors=COLORS, method=Image.Quantize.MAXCOVERAGE, kmeans=4, dither=Image.Dither.NONE).convert("RGB")
|
||||
im.save(f"{D}/{c['id']}-{SIZE}.png", optimize=True)
|
||||
atlas.paste(im, ((i % COLS) * SIZE, (i // COLS) * SIZE))
|
||||
order.append(c["kind"])
|
||||
atlas.save(f"{OUT}/portraits.png", optimize=True)
|
||||
open(f"{OUT}/order.txt", "w").write("\n".join(order) + "\n")
|
||||
print(atlas.size, os.path.getsize(f"{OUT}/portraits.png"), "bytes")
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,31 @@
|
||||
Worker
|
||||
Kobold
|
||||
Spider
|
||||
Mage
|
||||
Ogre
|
||||
Knight
|
||||
Archer
|
||||
Priest
|
||||
Dwarf
|
||||
Paladin
|
||||
Jailer
|
||||
Priestess
|
||||
Chainbreaker
|
||||
Bat
|
||||
Gargoyle
|
||||
Owl
|
||||
Wyrm
|
||||
Cogling
|
||||
Revenant
|
||||
Giant
|
||||
Fairy
|
||||
BattleMage
|
||||
Ram
|
||||
Catapult
|
||||
CaveBear
|
||||
Beetle
|
||||
Trader
|
||||
Thief
|
||||
Monk
|
||||
Ranger
|
||||
Inquisitor
|
||||
|
After Width: | Height: | Size: 432 KiB |
@@ -28,6 +28,7 @@ mod menu;
|
||||
mod netplay;
|
||||
mod personality;
|
||||
mod platform;
|
||||
mod portraits;
|
||||
mod orders;
|
||||
mod particles;
|
||||
mod props;
|
||||
|
||||
@@ -174,7 +174,19 @@ impl Menu {
|
||||
_ => level,
|
||||
};
|
||||
match entry {
|
||||
Entry::Unit(kind, _) => draw_sprite(gfx, kind, show_level, x + k, y + k, pic),
|
||||
Entry::Unit(kind, _) => {
|
||||
// the high-res style shows the creature's portrait filling the box
|
||||
let box_size = pic + 2.0 * k;
|
||||
match crate::portraits::rect(kind).filter(|_| crate::gfx::high_res_ui()) {
|
||||
Some(src) => {
|
||||
let m = 2.0;
|
||||
draw_texture_ex(&crate::portraits::atlas(), x + m, y + m, WHITE, DrawTextureParams { source: Some(src), dest_size: Some(vec2(box_size - 2.0 * m, box_size - 2.0 * m)), ..Default::default() });
|
||||
draw_rectangle_lines(x + m, y + m, box_size - 2.0 * m, box_size - 2.0 * m, 2.0, Color::new(0.0, 0.0, 0.0, 0.8));
|
||||
draw_rectangle_lines(x, y, box_size, box_size, 2.0, FRAME);
|
||||
}
|
||||
None => draw_sprite(gfx, kind, show_level, x + k, y + k, pic),
|
||||
}
|
||||
}
|
||||
Entry::Rank(l) => draw_sprite(gfx, EK::Ogre, l, x + k, y + k, pic),
|
||||
Entry::Building(i) => {
|
||||
let icon = tool_info(TOOLS[i]).icon;
|
||||
@@ -321,3 +333,15 @@ fn draw_sprite(gfx: &Gfx, kind: EK, level: u32, x: f32, y: f32, size: f32) {
|
||||
let src = Gfx::sprite_cell(kind, false, level);
|
||||
draw_texture_ex(&gfx.sprites, x, y, WHITE, DrawTextureParams { source: Some(src), dest_size: Some(vec2(size, size)), ..Default::default() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_bestiary_unit_has_a_portrait() {
|
||||
for &k in CREATURES.iter().chain(HUMANS.iter()) {
|
||||
assert!(crate::portraits::rect(k).is_some(), "no portrait for {:?}", k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Bestiary portraits for the high-res style: one 128x128 pixel-art bust per creature kind in one
|
||||
// atlas (8 columns), generated from the creatures' sprites (see art/generated/portraits/).
|
||||
|
||||
use crate::game::EK;
|
||||
use macroquad::prelude::*;
|
||||
|
||||
const PNG: &[u8] = include_bytes!("../assets/portraits/portraits.png");
|
||||
pub const CELL: f32 = 128.0;
|
||||
const COLS: usize = 8;
|
||||
|
||||
// the atlas order, the same as assets/portraits/order.txt
|
||||
const ORDER: [EK; 31] = [
|
||||
EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest,
|
||||
EK::Dwarf, EK::Paladin, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Bat, EK::Gargoyle, EK::Owl,
|
||||
EK::Wyrm, EK::Cogling, EK::Revenant, EK::Giant, EK::Fairy, EK::BattleMage, EK::Ram, EK::Catapult,
|
||||
EK::CaveBear, EK::Beetle, EK::Trader, EK::Thief, EK::Monk, EK::Ranger, EK::Inquisitor,
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static ATLAS: Texture2D = {
|
||||
let t = Texture2D::from_file_with_format(PNG, Some(ImageFormat::Png));
|
||||
t.set_filter(FilterMode::Nearest);
|
||||
t
|
||||
};
|
||||
}
|
||||
|
||||
// the atlas texture (loaded on first use)
|
||||
pub fn atlas() -> Texture2D {
|
||||
ATLAS.with(|t| t.clone())
|
||||
}
|
||||
|
||||
// the source rect of a kind's portrait in the atlas, None for kinds without one
|
||||
pub fn rect(kind: EK) -> Option<Rect> {
|
||||
let i = ORDER.iter().position(|&k| k == kind)?;
|
||||
Some(Rect::new((i % COLS) as f32 * CELL, (i / COLS) as f32 * CELL, CELL, CELL))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn atlas_decodes_with_room_for_every_portrait() {
|
||||
let img = Image::from_file_with_format(PNG, Some(ImageFormat::Png)).expect("atlas decodes");
|
||||
for &k in ORDER.iter() {
|
||||
let r = rect(k).unwrap();
|
||||
assert!(r.x + r.w <= img.width as f32 && r.y + r.h <= img.height as f32, "{:?} outside the atlas", k);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_matches_list() {
|
||||
let list: Vec<&str> = include_str!("../assets/portraits/order.txt").lines().collect();
|
||||
let ours: Vec<String> = ORDER.iter().map(|k| format!("{:?}", k)).collect();
|
||||
assert_eq!(list, ours);
|
||||
}
|
||||
}
|
||||