diff --git a/.gitignore b/.gitignore index dcdbce5..02f9b16 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ www/mq_js_bundle.js play/ dist/ art/generated/creature-previews/ +art/generated/sprites/*-raw*.png diff --git a/art/generated/sprites/PROMPTS.md b/art/generated/sprites/PROMPTS.md new file mode 100644 index 0000000..2c119ac --- /dev/null +++ b/art/generated/sprites/PROMPTS.md @@ -0,0 +1,401 @@ +# Creature sprites in the imp's style (fal.ai) + +Generated on 2026-11-07: one idle sprite sheet per creature kind of `SPRITE_KINDS` in +`src/gfx.rs` (all except `EK::Worker`, the imp, see `../imp-sprite/`), in the style of the imp's +`raw-1.png` lower row. Each creature has `-32.png` (160x32) and `-64.png` (320x64): five +views side by side (front, front three-quarter turned right, side facing right, back +three-quarter turned away to the right, back), transparent, one palette per sheet, feet (or the +lowest pixel) on the bottom row, the tallest or widest view filling the cell. Ids are the +`unit.` keys; `manifest.json` maps them to the `EK` variants with a quality note. + +## Generation + +- Model: `fal-ai/nano-banana-pro/edit` (POST https://fal.run/fal-ai/nano-banana-pro/edit) +- `aspect_ratio: 21:9`, `resolution: 2K`, `num_images: 1`, `output_format: png` +- Reference image (`image_urls`, as a data URI): `style-ref.png` = the lower row of + `../imp-sprite/raw-1.png` (crop fractions 0,0.27,1,0.72) scaled to 1600 px wide, the dark + cell frame lines painted over with magenta (the kobold, the first request, still had a + reference with frame lines and copied them). +- Background: flat magenta, flat green (#00FF00) for the purple creatures (spider, mage, bat, trader). +- 34 images: 30 creatures + 4 retries (spider, dwarf, revenant, ram; each had the wrong + number or order of views). For dwarf and ram the retry gave the missing back three-quarter + view but lost another one, so both tries are combined. About $5 at ~$0.15 per 2K image. +- Scripts in `scripts/`: `creatures.py` (descriptions, prompt template), `gen.py` (requests, + 4 at a time; reads the key from `~/.config/dungeoncraft/fal-key`), `compose.py` (sets chosen + figures of one or more raw sheets into one new row), `process.py` (runs the pixelize tool for + all creatures with the arguments of `extra.txt`). + +Even with "exactly five figures in one row" in the prompt the model often drew two rows (mostly +two copies of the five views, then the top row is used with `--crop 0,0,1,0.5`) or skipped +the back three-quarter view. + +### Prompt template + +`{thing}` is `creature` or `siege engine`; `{pose}` is `standing, idle`, `hovering in the air, +idle` (bat, gargoyle, owl, wyrm, fairy) or `standing still` (ram, catapult); `{bgname}` is +`magenta (#FF00FF)` or `green (#00FF00)`. The kobold and the first spider request were sent +without "exactly five figures in one row (no second row, no extra figures)," (the layout line +read "the same five views in one single horizontal row,"). + +```text +The attached image is the style reference: a pixel art sprite sheet of the "Pit Imp" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). + +Draw a DIFFERENT {thing} in exactly the same style: the "{name}". {desc} + +Use exactly the same style as the reference: the same compact chibi proportions (big head, short stocky body, about the same overall size as the imp), the same thick one-pixel near-black outline, the same palette approach (flat colours with one highlight and one shadow tone), the same pixel size (about 32x32 pixels per figure, scaled up with hard square pixels), no anti-aliasing, no gradients, no blur. + +Layout: exactly five figures in one single horizontal row (no second row, no extra figures), the same five views, in the same order (1 front view facing the viewer, 2 front three-quarter view turned to the right, 3 side view facing right, 4 back three-quarter view turned away to the right, 5 back view), evenly spaced, all the same size, {pose}, each figure separated from its neighbours by empty background. + +Background: flat pure {bgname} everywhere, no ground, no shadows, no text, no labels, no frames, no grid lines, no cell borders. +``` + +Retries (dwarf, revenant) appended: + +```text +Important: there must be exactly FIVE figures in ONE row. Do not skip the fourth one: the back three-quarter view, seen from behind at an angle, the face hidden, the body turned away from the viewer towards the upper right, between the side view and the straight back view. +``` + +The ram retry appended instead: + +```text +Important: there must be exactly FIVE drawings of the machine in ONE row, no second row. 1 front view: the iron-capped end of the ram points straight at the viewer. 2 front three-quarter view: the capped end points towards the viewer and to the right. 3 side view: the capped end points to the right. 4 back three-quarter view: the capped end points away from the viewer towards the upper right, we see the rear end of the machine. 5 back view: we see the rear end straight on. +``` + +## Pixel grid + +`tools/pixelize_sprite.py` (Pillow: `/tmp/sprvenv/bin/python -I`), from the repo root: + +```bash +T=tools/pixelize_sprite.py +python $T art/generated/sprites/-raw.png art/generated/sprites/ --views 5 --size 32 --colors 16 --feet 31 --outline-weight 0.6 [extra] +python $T art/generated/sprites/-raw.png art/generated/sprites/ --views 5 --size 64 --colors 24 --feet 63 --outline-weight 0.6 [extra] +``` + +(the tool also writes `--preview.png`; those were deleted). `[extra]` per creature is +listed below. Composed raws were made first with `scripts/compose.py` (run from `scripts/`). + +### Tool changes (backwards compatible) + +- Background key `--bg auto` (default): the key colours are sampled from the sheet's border and + only colours close to them (and their blends with the dark outline) are removed, so purple + creatures on green survive; `--bg magenta` is the old fixed test, `--bg R,G,B` an explicit key. +- Blends of the key colour with thin figure parts (pinkish edge pixels on a spear) get no vote. +- Frame/grid lines (a column or row at least 97% one flat non-background colour) are removed, + plus their anti-aliased edges. +- `--split auto` (default): figures are found by the empty columns between them, else by + connected parts, else by cutting touching figures at their thinnest column, else equal cells + (`--split cells`, the old behaviour); small pieces of a neighbour at a cell edge are dropped. +- `--row-counts 4,3` (rows with different numbers of figures) and `--pick 0,1,2,m5,3` (build + the output row from chosen figures, `m` mirrors). +- Single pixels without any neighbour are removed (`--no-despeckle` keeps them). +- The imp's `raw-1.png` gives the same sheet as before (42 of 5120 pixels differ in coverage). + +## Creatures + +### kobold - Kobold (`EK::Kobold`) + +Description (standing, magenta background): A small scaly lizard-folk warrior: short and wiry, big lizard-like head with a short snout, bright green scaly skin, glowing yellow eyes, a toothy eager grin with sharp little teeth, a simple dark-green tunic, clawed bare feet, a short tail. It holds an upright wooden spear with a steel tip in its right hand. + +Raw: kobold-raw.png + +Extra pixelize arguments: none + +Result: ok - good - chibi lizard, spear in all views; raw has frame lines (removed by the tool) + +### spider - Cave Spider (`EK::Spider`) + +Description (standing, green background): A big dungeon spider: a big round body and head in dark violet-purple with lighter purple highlights, a cluster of glowing yellow eyes on the front of the head, eight thick black jointed legs spread around it, small fangs. Low to the ground, compact and menacing, cute-chibi proportions with an oversized head. + +Raw: spider-raw-try1.png (2 rows of 3, dropped), spider-raw.png (retry, 2 rows of 4, used) + +Extra pixelize arguments: `--grid 2x4 --pick 0,1,2,3,3` + +Result: ok - usable - both tries came as 2 rows without a back three-quarter view; the back view is used twice (views 4 and 5 identical) + +### mage - Shadow Mage (`EK::Mage`) + +Description (standing, green background): A hooded dark sorcerer: a big pointed purple hood that hides most of the oversized head, the face in shadow with only a pale chin and two glowing yellow eyes visible, dark-purple robes with light violet trim down to the feet, a wooden staff with a glowing yellow gem on top held in the right hand. + +Raw: mage-raw.png + +Extra pixelize arguments: none + +Result: ok - good - green key, purple hood and robe survive, glowing eyes, gem staff + +### ogre - Stone Ogre (`EK::Ogre`) + +Description (standing, magenta background): A hulking ogre with skin like rock: broad and hunched, almost no neck, skin of grey stone with darker grey cracks and patches, small glowing yellow eyes, a heavy jaw with an underbite, enormous arms and fists reaching low, short thick legs, a ragged brown loincloth. No weapon - it fights with its fists. Chibi-stocky, as wide as it is tall. + +Raw: ogre-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - stocky stone ogre; raw has 2 rows (top row used); back three-quarter drawn almost as a side view + +### knight - Knight (`EK::Knight`) + +Description (standing, magenta background): A heavily armoured knight of the holy Order: a big polished silver great helm with a dark visor slit, silver shoulder plates and greaves, a blue tabard with a yellow sun emblem on the chest over chainmail, dark boots, a short straight steel sword held upright in the right hand. + +Raw: knight-raw.png + +Extra pixelize arguments: none + +Result: ok - good - great helm, blue tabard with sun, sword + +### archer - Archer (`EK::Archer`) + +Description (standing, magenta background): A hero archer of the Order: a little green hat with a peak, a friendly human face with brown hair, a green tunic with a brown belt, brown trousers and leather boots, a quiver of arrows on the back, holding a wooden bow upright in the left hand. + +Raw: archer-raw.png + +Extra pixelize arguments: none + +Result: ok - good - a bit taller than the imp, bow and quiver readable + +### priest - Priest (`EK::Priest`) + +Description (standing, magenta background): A healing priest of the Order: a human with short brown hair and a kind face, a long plain white robe down to the feet with a golden sun emblem on the chest, a small golden halo ring floating above the head, hands folded in prayer. + +Raw: priest-raw.png + +Extra pixelize arguments: none + +Result: ok - good - white robe, sun emblem, floating halo + +### dwarf - Dwarf Miner (`EK::Dwarf`) + +Description (standing, magenta background): A stout dwarf miner of the Order: very short and broad, a grey steel helmet, a big bushy orange beard, a skin-coloured nose and eyes peeking out, a brown leather jerkin and belt, brown trousers and boots, a steel pickaxe with a wooden handle over the right shoulder. + +Raw: dwarf-raw-try1.png, dwarf-raw-try2.png (retry) -> composed dwarf-raw.png + +Compose: `python compose.py art/generated/sprites/dwarf-raw.png art/generated/sprites/dwarf-raw-try1.png:1:4:0 art/generated/sprites/dwarf-raw-try1.png:1:4:1 art/generated/sprites/dwarf-raw-try1.png:1:4:2 art/generated/sprites/dwarf-raw-try2.png:1:4:3 art/generated/sprites/dwarf-raw-try1.png:1:4:3` + +Extra pixelize arguments: none + +Result: ok - good - composed from two tries (each had only 4 views): try 1 front/front 3/4/side/back + try 2 back three-quarter + +### paladin - Paladin of Light (`EK::Paladin`) + +Description (standing, magenta background): A big glowing holy champion: a golden helmet with a dark visor, shining white plate armour with golden trim and a golden sun on the chest, golden greaves, broad shoulders, a white holy sword with a golden hilt held upright in the right hand. Bulkier than the other characters but still chibi. + +Raw: paladin-raw.png + +Extra pixelize arguments: none + +Result: ok - good - white/gold armour, big sword; very bright + +### jailer - Jailer (`EK::Jailer`) + +Description (standing, magenta background): A big quiet dungeon jailer: broad-shouldered and heavy, a grey leather executioner's hood with eye holes and glowing yellow eyes, grey-skinned bare muscular arms, a dark-red leather apron over the chest and belly, heavy boots, a coiled iron chain hanging from the right hand. + +Raw: jailer-raw.png + +Extra pixelize arguments: none + +Result: ok - good - hood, grey arms, red apron, chain + +### priestess - Flame Priestess (`EK::Priestess`) + +Description (standing, magenta background): A fire priestess of the dungeon: a woman with fair skin, flame-orange hair rising like fire, a long dark-red robe to the feet with an orange flame emblem on the chest, a small burning fireball floating above her raised right hand. + +Raw: priestess-raw.png + +Extra pixelize arguments: none + +Result: ok - good - flame hair, red robe, fireball + +### chainbreaker - Chainbreaker (`EK::Chainbreaker`) + +Description (standing, magenta background): A legendary obsidian demon warrior: a muscular demon of black obsidian with glowing orange-lava cracks across the body, glowing yellow eyes, short curved horns, broken iron shackles with dangling chain links on both wrists, a steel battle axe with a light grey blade in each hand, heavy clawed feet. + +Raw: chainbreaker-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - obsidian with lava cracks, horns, axes; raw has 2 rows (top row used); dark at 32 px + +### bat - Giant Bat (`EK::Bat`) + +Description (hovering, green background): A big dungeon bat: a round furry dark-purple body with lighter purple highlights, leathery purple wings spread out with black finger bones, big pointed ears, small glowing red eyes, a little fanged mouth, tiny clawed feet hanging down. Hovering in the air, wings spread but kept compact within its own cell. + +Raw: bat-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - green key, purple body and wings survive; raw has 2 rows (top row used) + +### gargoyle - Chain Gargoyle (`EK::Gargoyle`) + +Description (hovering, magenta background): A winged stone gargoyle: a crouching, hunched creature of carved grey stone with darker grey cracks, stone bat wings with pale tips, short curved horns, glowing yellow eyes, a grim mouth with fangs, clawed hands and feet, an iron chain wrapped around one arm. Hovering in the air. + +Raw: gargoyle-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - grey stone, wings, chain; raw has 2 rows (top row used) + +### owl - Arcane Owl (`EK::Owl`) + +Description (hovering, magenta background): A wise magical owl: a round brown-feathered owl with lighter brown and cream breast feathers, tufted ear feathers, huge glowing yellow eyes with black pupils, a small yellow beak, wings with deep blue magical tips, yellow talons, a judging expression. Hovering in the air with its wings half spread. + +Raw: owl-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - round chibi owl, blue wing tips; raw has 2 rows (top row used) + +### wyrm - Ember Wyrm (`EK::Wyrm`) + +Description (hovering, magenta background): A small fire dragon: a compact red dragon with dark-red scales on the back, a glowing orange belly, dark-red bat wings, a single white horn, glowing yellow eyes, a curled tail, clawed legs, small embers of fire at its mouth. Hovering in the air. + +Raw: wyrm-raw.png + +Extra pixelize arguments: none + +Result: ok - good - red dragon, orange belly, white horn; tall and thin because of the hanging tail + +### cogling - Cogling (`EK::Cogling`) + +Description (standing, magenta background): A sooty little goblin tinkerer: tiny and round, burnt-orange sooty skin, big brass goggles with glowing yellow lenses over the eyes, a grey work overall over an orange shirt, small boots, a steel hammer held up in the right hand, a few small brass cogs on the belt. + +Raw: cogling-raw.png + +Extra pixelize arguments: `--crop 0,0,1,0.5` + +Result: ok - good - goggles, overall, hammer; raw has 2 rows (top row used) + +### revenant - Revenant (`EK::Revenant`) + +Description (standing, magenta background): An undead warrior: a gaunt pale corpse with a bony white skull face, hollow eye sockets with glowing green grave-light, tattered grey rags and old rusty armour pieces, thin bony arms and legs, a rusty steel sword held in the right hand. + +Raw: revenant-raw-try1.png (4 views, dropped), revenant-raw.png (retry, used) + +Extra pixelize arguments: none + +Result: ok - good - second try (first had only 4 views); big skull, green eyes, rusty sword + +### giant - Giant (`EK::Giant`) + +Description (standing, magenta background): A hero giant of the Order: a big broad human brute with fair skin, a small bald head with a dumb frown and small eyes, a brown leather jerkin over a blue shirt, bare muscular arms, heavy brown boots, a big wooden tree-trunk club resting on the right shoulder. Bulkier than the other characters but still chibi. + +Raw: giant-raw.png + +Extra pixelize arguments: none + +Result: ok - good - bald brute with club; drawn chibi, so not much bigger than the others (the game scales it) + +### fairy - Fairy Spy (`EK::Fairy`) + +Description (hovering, magenta background): A tiny glowing fairy informer: a small figure with fair skin, blonde hair and small pointed ears, glowing yellow eyes, a short blue dress, two pairs of translucent pale-blue dragonfly wings, a few golden sparkles below the feet. Hovering in the air. + +Raw: fairy-raw.png + +Extra pixelize arguments: none + +Result: ok - good - blue dress, pale wings; a few gold sparkles below the feet are part of the sprite, so the bottom row is the sparkles, not the feet + +### battlemage - Battle Mage (`EK::BattleMage`) + +Description (standing, magenta background): A fire mage of the Order: a tall pointed blue wizard hat, a human face with a stern look and a short grey beard, long blue robes with white trim and a white sash, a wooden staff in the right hand with a burning orange-yellow flame on top. + +Raw: battlemage-raw.png + +Extra pixelize arguments: none + +Result: ok - good - blue hat and robes, burning staff, grey beard + +### ram - Battering Ram (`EK::Ram`) + +Description (siege engine, magenta background): A wooden siege battering ram, no people visible: a compact wooden frame on four solid wooden wheels with a slanted brown plank roof; under the roof hangs a thick tree trunk with a big grey iron cap on its front end. Brown and dark-brown wood, iron rivets. Chunky toy-like proportions. Show the same five views of the machine (front, front three-quarter turned right, side facing right, back three-quarter, back). + +Raw: ram-raw-try1.png, ram-raw-try2.png (retry) -> composed ram-raw.png + +Compose: `python compose.py art/generated/sprites/ram-raw.png art/generated/sprites/ram-raw-try1.png:2:3,3:0 art/generated/sprites/ram-raw-try1.png:2:3,3:2 art/generated/sprites/ram-raw-try1.png:2:3,3:1 art/generated/sprites/ram-raw-try2.png:2:3,2:4 art/generated/sprites/ram-raw-try1.png:2:3,3:5` + +Extra pixelize arguments: none + +Result: ok - usable - composed from two tries (both 2 rows, views out of order): try 1 front/front 3/4/side/back + try 2 back three-quarter; the side view is wide, the 'back' view of try 1 may show the front end again + +### catapult - Catapult (`EK::Catapult`) + +Description (siege engine, magenta background): A wooden siege catapult, no people visible: a low rectangular wooden base on four wooden wheels, a wooden throwing arm angled upward with a cup at its tip holding a grey stone boulder, ropes and a crossbeam. Brown and dark-brown wood, iron fittings. Chunky toy-like proportions. Show the same five views of the machine (front, front three-quarter turned right, side facing right, back three-quarter, back). + +Raw: catapult-raw-orig.png -> composed catapult-raw.png + +Compose: `python compose.py art/generated/sprites/catapult-raw.png C:2:4,3:0 C:2:4,3:1 C:2:4,3:2 C:2:4,3:3 C:2:4,3:6::0.8 # C = art/generated/sprites/catapult-raw-orig.png` + +Extra pixelize arguments: none + +Result: ok - usable - raw came as 2 rows of 4+3; front/front 3/4/side/back 3/4 from the top row, back view from row 2 (scaled 0.8); noisy at 32 px + +### cavebear - Cave Bear (`EK::CaveBear`) + +Description (standing, magenta background): A shaggy cave bear: big, round and shaggy, thick dark-brown fur with lighter brown highlights, small round ears, small glowing yellow eyes, a light tan muzzle with a black nose, big paws with claws, standing on all four legs, grumpy. Chunky chibi proportions with a big head. + +Raw: cavebear-raw.png + +Extra pixelize arguments: `--grid 2x4 --row-counts 4,2 --pick 0,1,2,m5,3` + +Result: ok - good - four-legged bear; raw has 2 rows of 4+2; back three-quarter is a mirrored figure of row 2 + +### beetle - Giant Beetle (`EK::Beetle`) + +Description (standing, magenta background): A beetle the size of a barrel: a big round shiny dark-green shell with lighter green highlights and a line down the middle, a dark head with glowing yellow eyes, two curved black mandibles and two short antennae, six short black legs. Low on the ground, chunky and round. + +Raw: beetle-raw-orig.png -> composed beetle-raw.png + +Compose: `python compose.py art/generated/sprites/beetle-raw.png B:2:4,2:0 B:2:4,2:1 B:2:4,2:2 B:2:4,2:5::0.78 B:2:4,2:3 # B = art/generated/sprites/beetle-raw-orig.png` + +Extra pixelize arguments: none + +Result: ok - good - green shell, yellow eyes; raw has 2 rows of 4+2; back three-quarter from row 2 (scaled 0.78) + +### trader - Trader (`EK::Trader`) + +Description (standing, green background): A wandering hooded merchant: a deep purple hooded cloak with a friendly human face peeking out, a yellow clasp on the chest, a big brown leather backpack piled high with goods, brown boots, a small glowing lantern held in the right hand. + +Raw: trader-raw.png + +Extra pixelize arguments: none + +Result: ok - good - green key, purple cloak survives, backpack, lantern + +### thief - Thief (`EK::Thief`) + +Description (standing, magenta background): A sneaky thief: a dark charcoal-grey hood and a black mask over the lower face with only the eyes showing, close-fitting dark-grey clothes with a brown belt and soft brown boots, a short steel dagger in the right hand, a bulging tan sack of gold coins slung over the back. + +Raw: thief-raw.png + +Extra pixelize arguments: none + +Result: ok - good - dark hood and mask, dagger, tan loot sack + +### monk - Monk (`EK::Monk`) + +Description (standing, magenta background): A fighting monk of the Order: a shaven bald head, calm face with closed eyes, a simple orange robe with darker orange shading, tied with a yellow rope belt, bare arms and bare fists raised in a ready fighting stance, sandals. + +Raw: monk-raw.png + +Extra pixelize arguments: none + +Result: ok - good - bald, orange robe, fists raised + +### ranger - Ranger (`EK::Ranger`) + +Description (standing, magenta background): A woodsman ranger of the Order: a green hood and green cloak, a human face, a green tunic with a brown leather belt and strap, brown leather trousers and boots, a wooden bow held upright in the left hand, a small iron snare trap hanging from the belt. + +Raw: ranger-raw.png + +Extra pixelize arguments: none + +Result: ok - good - green hood and cloak, bow, snare + +### inquisitor - Inquisitor (`EK::Inquisitor`) + +Description (standing, magenta background): A frail zealous preacher of the Order: a thin old man with a tall dark-red mitre hat, a pale face with narrowed eyes, long white robes with a red stripe down the middle and red hems, holding open a glowing yellow holy book in front of him. + +Raw: inquisitor-raw.png + +Extra pixelize arguments: none + +Result: ok - good - red mitre, white/red robes, glowing book; looks less frail than described diff --git a/art/generated/sprites/archer-32.png b/art/generated/sprites/archer-32.png new file mode 100644 index 0000000..cb4e8ed Binary files /dev/null and b/art/generated/sprites/archer-32.png differ diff --git a/art/generated/sprites/archer-64.png b/art/generated/sprites/archer-64.png new file mode 100644 index 0000000..8d7fca7 Binary files /dev/null and b/art/generated/sprites/archer-64.png differ diff --git a/art/generated/sprites/bat-32.png b/art/generated/sprites/bat-32.png new file mode 100644 index 0000000..be7c67a Binary files /dev/null and b/art/generated/sprites/bat-32.png differ diff --git a/art/generated/sprites/bat-64.png b/art/generated/sprites/bat-64.png new file mode 100644 index 0000000..b7f00f0 Binary files /dev/null and b/art/generated/sprites/bat-64.png differ diff --git a/art/generated/sprites/battlemage-32.png b/art/generated/sprites/battlemage-32.png new file mode 100644 index 0000000..8f3f20b Binary files /dev/null and b/art/generated/sprites/battlemage-32.png differ diff --git a/art/generated/sprites/battlemage-64.png b/art/generated/sprites/battlemage-64.png new file mode 100644 index 0000000..7191528 Binary files /dev/null and b/art/generated/sprites/battlemage-64.png differ diff --git a/art/generated/sprites/beetle-32.png b/art/generated/sprites/beetle-32.png new file mode 100644 index 0000000..1fd2c74 Binary files /dev/null and b/art/generated/sprites/beetle-32.png differ diff --git a/art/generated/sprites/beetle-64.png b/art/generated/sprites/beetle-64.png new file mode 100644 index 0000000..19274bc Binary files /dev/null and b/art/generated/sprites/beetle-64.png differ diff --git a/art/generated/sprites/catapult-32.png b/art/generated/sprites/catapult-32.png new file mode 100644 index 0000000..8ca7c07 Binary files /dev/null and b/art/generated/sprites/catapult-32.png differ diff --git a/art/generated/sprites/catapult-64.png b/art/generated/sprites/catapult-64.png new file mode 100644 index 0000000..998ec15 Binary files /dev/null and b/art/generated/sprites/catapult-64.png differ diff --git a/art/generated/sprites/cavebear-32.png b/art/generated/sprites/cavebear-32.png new file mode 100644 index 0000000..db0021c Binary files /dev/null and b/art/generated/sprites/cavebear-32.png differ diff --git a/art/generated/sprites/cavebear-64.png b/art/generated/sprites/cavebear-64.png new file mode 100644 index 0000000..9809b29 Binary files /dev/null and b/art/generated/sprites/cavebear-64.png differ diff --git a/art/generated/sprites/chainbreaker-32.png b/art/generated/sprites/chainbreaker-32.png new file mode 100644 index 0000000..1542458 Binary files /dev/null and b/art/generated/sprites/chainbreaker-32.png differ diff --git a/art/generated/sprites/chainbreaker-64.png b/art/generated/sprites/chainbreaker-64.png new file mode 100644 index 0000000..0290fe8 Binary files /dev/null and b/art/generated/sprites/chainbreaker-64.png differ diff --git a/art/generated/sprites/cogling-32.png b/art/generated/sprites/cogling-32.png new file mode 100644 index 0000000..c54177c Binary files /dev/null and b/art/generated/sprites/cogling-32.png differ diff --git a/art/generated/sprites/cogling-64.png b/art/generated/sprites/cogling-64.png new file mode 100644 index 0000000..056178e Binary files /dev/null and b/art/generated/sprites/cogling-64.png differ diff --git a/art/generated/sprites/dwarf-32.png b/art/generated/sprites/dwarf-32.png new file mode 100644 index 0000000..2017814 Binary files /dev/null and b/art/generated/sprites/dwarf-32.png differ diff --git a/art/generated/sprites/dwarf-64.png b/art/generated/sprites/dwarf-64.png new file mode 100644 index 0000000..cd27e39 Binary files /dev/null and b/art/generated/sprites/dwarf-64.png differ diff --git a/art/generated/sprites/fairy-32.png b/art/generated/sprites/fairy-32.png new file mode 100644 index 0000000..8ad14bd Binary files /dev/null and b/art/generated/sprites/fairy-32.png differ diff --git a/art/generated/sprites/fairy-64.png b/art/generated/sprites/fairy-64.png new file mode 100644 index 0000000..8b00fad Binary files /dev/null and b/art/generated/sprites/fairy-64.png differ diff --git a/art/generated/sprites/gargoyle-32.png b/art/generated/sprites/gargoyle-32.png new file mode 100644 index 0000000..2c58d00 Binary files /dev/null and b/art/generated/sprites/gargoyle-32.png differ diff --git a/art/generated/sprites/gargoyle-64.png b/art/generated/sprites/gargoyle-64.png new file mode 100644 index 0000000..a6b5aea Binary files /dev/null and b/art/generated/sprites/gargoyle-64.png differ diff --git a/art/generated/sprites/giant-32.png b/art/generated/sprites/giant-32.png new file mode 100644 index 0000000..e8f06f1 Binary files /dev/null and b/art/generated/sprites/giant-32.png differ diff --git a/art/generated/sprites/giant-64.png b/art/generated/sprites/giant-64.png new file mode 100644 index 0000000..bc58eb0 Binary files /dev/null and b/art/generated/sprites/giant-64.png differ diff --git a/art/generated/sprites/inquisitor-32.png b/art/generated/sprites/inquisitor-32.png new file mode 100644 index 0000000..238e7da Binary files /dev/null and b/art/generated/sprites/inquisitor-32.png differ diff --git a/art/generated/sprites/inquisitor-64.png b/art/generated/sprites/inquisitor-64.png new file mode 100644 index 0000000..e161651 Binary files /dev/null and b/art/generated/sprites/inquisitor-64.png differ diff --git a/art/generated/sprites/jailer-32.png b/art/generated/sprites/jailer-32.png new file mode 100644 index 0000000..21732b6 Binary files /dev/null and b/art/generated/sprites/jailer-32.png differ diff --git a/art/generated/sprites/jailer-64.png b/art/generated/sprites/jailer-64.png new file mode 100644 index 0000000..7ccebde Binary files /dev/null and b/art/generated/sprites/jailer-64.png differ diff --git a/art/generated/sprites/knight-32.png b/art/generated/sprites/knight-32.png new file mode 100644 index 0000000..31f5900 Binary files /dev/null and b/art/generated/sprites/knight-32.png differ diff --git a/art/generated/sprites/knight-64.png b/art/generated/sprites/knight-64.png new file mode 100644 index 0000000..6335382 Binary files /dev/null and b/art/generated/sprites/knight-64.png differ diff --git a/art/generated/sprites/kobold-32.png b/art/generated/sprites/kobold-32.png new file mode 100644 index 0000000..c97b9dd Binary files /dev/null and b/art/generated/sprites/kobold-32.png differ diff --git a/art/generated/sprites/kobold-64.png b/art/generated/sprites/kobold-64.png new file mode 100644 index 0000000..8c90a4b Binary files /dev/null and b/art/generated/sprites/kobold-64.png differ diff --git a/art/generated/sprites/mage-32.png b/art/generated/sprites/mage-32.png new file mode 100644 index 0000000..a73dacd Binary files /dev/null and b/art/generated/sprites/mage-32.png differ diff --git a/art/generated/sprites/mage-64.png b/art/generated/sprites/mage-64.png new file mode 100644 index 0000000..973f78d Binary files /dev/null and b/art/generated/sprites/mage-64.png differ diff --git a/art/generated/sprites/manifest.json b/art/generated/sprites/manifest.json new file mode 100644 index 0000000..6d3513f --- /dev/null +++ b/art/generated/sprites/manifest.json @@ -0,0 +1,182 @@ +[ + { + "id": "kobold", + "kind": "Kobold", + "ok": true, + "note": "good - chibi lizard, spear in all views; raw has frame lines (removed by the tool)" + }, + { + "id": "spider", + "kind": "Spider", + "ok": true, + "note": "usable - both tries came as 2 rows without a back three-quarter view; the back view is used twice (views 4 and 5 identical)" + }, + { + "id": "mage", + "kind": "Mage", + "ok": true, + "note": "good - green key, purple hood and robe survive, glowing eyes, gem staff" + }, + { + "id": "ogre", + "kind": "Ogre", + "ok": true, + "note": "good - stocky stone ogre; raw has 2 rows (top row used); back three-quarter drawn almost as a side view" + }, + { + "id": "knight", + "kind": "Knight", + "ok": true, + "note": "good - great helm, blue tabard with sun, sword" + }, + { + "id": "archer", + "kind": "Archer", + "ok": true, + "note": "good - a bit taller than the imp, bow and quiver readable" + }, + { + "id": "priest", + "kind": "Priest", + "ok": true, + "note": "good - white robe, sun emblem, floating halo" + }, + { + "id": "dwarf", + "kind": "Dwarf", + "ok": true, + "note": "good - composed from two tries (each had only 4 views): try 1 front/front 3/4/side/back + try 2 back three-quarter" + }, + { + "id": "paladin", + "kind": "Paladin", + "ok": true, + "note": "good - white/gold armour, big sword; very bright" + }, + { + "id": "jailer", + "kind": "Jailer", + "ok": true, + "note": "good - hood, grey arms, red apron, chain" + }, + { + "id": "priestess", + "kind": "Priestess", + "ok": true, + "note": "good - flame hair, red robe, fireball" + }, + { + "id": "chainbreaker", + "kind": "Chainbreaker", + "ok": true, + "note": "good - obsidian with lava cracks, horns, axes; raw has 2 rows (top row used); dark at 32 px" + }, + { + "id": "bat", + "kind": "Bat", + "ok": true, + "note": "good - green key, purple body and wings survive; raw has 2 rows (top row used)" + }, + { + "id": "gargoyle", + "kind": "Gargoyle", + "ok": true, + "note": "good - grey stone, wings, chain; raw has 2 rows (top row used)" + }, + { + "id": "owl", + "kind": "Owl", + "ok": true, + "note": "good - round chibi owl, blue wing tips; raw has 2 rows (top row used)" + }, + { + "id": "wyrm", + "kind": "Wyrm", + "ok": true, + "note": "good - red dragon, orange belly, white horn; tall and thin because of the hanging tail" + }, + { + "id": "cogling", + "kind": "Cogling", + "ok": true, + "note": "good - goggles, overall, hammer; raw has 2 rows (top row used)" + }, + { + "id": "revenant", + "kind": "Revenant", + "ok": true, + "note": "good - second try (first had only 4 views); big skull, green eyes, rusty sword" + }, + { + "id": "giant", + "kind": "Giant", + "ok": true, + "note": "good - bald brute with club; drawn chibi, so not much bigger than the others (the game scales it)" + }, + { + "id": "fairy", + "kind": "Fairy", + "ok": true, + "note": "good - blue dress, pale wings; a few gold sparkles below the feet are part of the sprite, so the bottom row is the sparkles, not the feet" + }, + { + "id": "battlemage", + "kind": "BattleMage", + "ok": true, + "note": "good - blue hat and robes, burning staff, grey beard" + }, + { + "id": "ram", + "kind": "Ram", + "ok": true, + "note": "usable - composed from two tries (both 2 rows, views out of order): try 1 front/front 3/4/side/back + try 2 back three-quarter; the side view is wide, the 'back' view of try 1 may show the front end again" + }, + { + "id": "catapult", + "kind": "Catapult", + "ok": true, + "note": "usable - raw came as 2 rows of 4+3; front/front 3/4/side/back 3/4 from the top row, back view from row 2 (scaled 0.8); noisy at 32 px" + }, + { + "id": "cavebear", + "kind": "CaveBear", + "ok": true, + "note": "good - four-legged bear; raw has 2 rows of 4+2; back three-quarter is a mirrored figure of row 2" + }, + { + "id": "beetle", + "kind": "Beetle", + "ok": true, + "note": "good - green shell, yellow eyes; raw has 2 rows of 4+2; back three-quarter from row 2 (scaled 0.78)" + }, + { + "id": "trader", + "kind": "Trader", + "ok": true, + "note": "good - green key, purple cloak survives, backpack, lantern" + }, + { + "id": "thief", + "kind": "Thief", + "ok": true, + "note": "good - dark hood and mask, dagger, tan loot sack" + }, + { + "id": "monk", + "kind": "Monk", + "ok": true, + "note": "good - bald, orange robe, fists raised" + }, + { + "id": "ranger", + "kind": "Ranger", + "ok": true, + "note": "good - green hood and cloak, bow, snare" + }, + { + "id": "inquisitor", + "kind": "Inquisitor", + "ok": true, + "note": "good - red mitre, white/red robes, glowing book; looks less frail than described" + } +] \ No newline at end of file diff --git a/art/generated/sprites/monk-32.png b/art/generated/sprites/monk-32.png new file mode 100644 index 0000000..754d91b Binary files /dev/null and b/art/generated/sprites/monk-32.png differ diff --git a/art/generated/sprites/monk-64.png b/art/generated/sprites/monk-64.png new file mode 100644 index 0000000..8baa442 Binary files /dev/null and b/art/generated/sprites/monk-64.png differ diff --git a/art/generated/sprites/ogre-32.png b/art/generated/sprites/ogre-32.png new file mode 100644 index 0000000..b14b0c5 Binary files /dev/null and b/art/generated/sprites/ogre-32.png differ diff --git a/art/generated/sprites/ogre-64.png b/art/generated/sprites/ogre-64.png new file mode 100644 index 0000000..0d1db2b Binary files /dev/null and b/art/generated/sprites/ogre-64.png differ diff --git a/art/generated/sprites/owl-32.png b/art/generated/sprites/owl-32.png new file mode 100644 index 0000000..6f48da5 Binary files /dev/null and b/art/generated/sprites/owl-32.png differ diff --git a/art/generated/sprites/owl-64.png b/art/generated/sprites/owl-64.png new file mode 100644 index 0000000..750f2ff Binary files /dev/null and b/art/generated/sprites/owl-64.png differ diff --git a/art/generated/sprites/paladin-32.png b/art/generated/sprites/paladin-32.png new file mode 100644 index 0000000..f8959b0 Binary files /dev/null and b/art/generated/sprites/paladin-32.png differ diff --git a/art/generated/sprites/paladin-64.png b/art/generated/sprites/paladin-64.png new file mode 100644 index 0000000..1848d55 Binary files /dev/null and b/art/generated/sprites/paladin-64.png differ diff --git a/art/generated/sprites/priest-32.png b/art/generated/sprites/priest-32.png new file mode 100644 index 0000000..5dd6da2 Binary files /dev/null and b/art/generated/sprites/priest-32.png differ diff --git a/art/generated/sprites/priest-64.png b/art/generated/sprites/priest-64.png new file mode 100644 index 0000000..d8c0b3a Binary files /dev/null and b/art/generated/sprites/priest-64.png differ diff --git a/art/generated/sprites/priestess-32.png b/art/generated/sprites/priestess-32.png new file mode 100644 index 0000000..7303f76 Binary files /dev/null and b/art/generated/sprites/priestess-32.png differ diff --git a/art/generated/sprites/priestess-64.png b/art/generated/sprites/priestess-64.png new file mode 100644 index 0000000..8a62bb0 Binary files /dev/null and b/art/generated/sprites/priestess-64.png differ diff --git a/art/generated/sprites/ram-32.png b/art/generated/sprites/ram-32.png new file mode 100644 index 0000000..298fc62 Binary files /dev/null and b/art/generated/sprites/ram-32.png differ diff --git a/art/generated/sprites/ram-64.png b/art/generated/sprites/ram-64.png new file mode 100644 index 0000000..1f7cf3b Binary files /dev/null and b/art/generated/sprites/ram-64.png differ diff --git a/art/generated/sprites/ranger-32.png b/art/generated/sprites/ranger-32.png new file mode 100644 index 0000000..4686c35 Binary files /dev/null and b/art/generated/sprites/ranger-32.png differ diff --git a/art/generated/sprites/ranger-64.png b/art/generated/sprites/ranger-64.png new file mode 100644 index 0000000..64ddb69 Binary files /dev/null and b/art/generated/sprites/ranger-64.png differ diff --git a/art/generated/sprites/revenant-32.png b/art/generated/sprites/revenant-32.png new file mode 100644 index 0000000..8fff61b Binary files /dev/null and b/art/generated/sprites/revenant-32.png differ diff --git a/art/generated/sprites/revenant-64.png b/art/generated/sprites/revenant-64.png new file mode 100644 index 0000000..f1e2e64 Binary files /dev/null and b/art/generated/sprites/revenant-64.png differ diff --git a/art/generated/sprites/scripts/compose.py b/art/generated/sprites/scripts/compose.py new file mode 100644 index 0000000..25ecf0f --- /dev/null +++ b/art/generated/sprites/scripts/compose.py @@ -0,0 +1,30 @@ +# compose.py out.png raw:rows:rowcounts:index[:m[:scale]] ... - cut chosen figures out of raw sheets and +# set them in one row on flat magenta (feet on one line, wide gaps), for pixelize_sprite.py --views N +import sys +sys.path.insert(0, "/home/zink/Projects/dungeoncraft/tools") +import pixelize_sprite as ps +from PIL import Image, ImageOps +out = sys.argv[1]; figs = [] +for spec in sys.argv[2:]: + parts = spec.split(":") + raw, rows, rc, idx = parts[0], int(parts[1]), [int(v) for v in parts[2].split(",")], int(parts[3]) + img = Image.open(raw).convert("RGB") + mask, boxes = ps.figure_boxes(img, rows, max(rc), "auto", "auto", rc) + x0, y0, x1, y1 = boxes[idx] + rgba = img.crop((x0, y0, x1, y1)).convert("RGBA") + # background pixels transparent so they become pure magenta on the new sheet + a = Image.new("L", rgba.size, 0) + a.putdata([255 if mask[y][x] else 0 for y in range(y0, y1) for x in range(x0, x1)]) + rgba.putalpha(a) + if len(parts) > 4 and parts[4] == "m": + rgba = ImageOps.mirror(rgba) + if len(parts) > 5: + k = float(parts[5]) + rgba = rgba.resize((round(rgba.width * k), round(rgba.height * k)), Image.NEAREST) + figs.append(rgba) +gap = 120 +W = sum(f.width for f in figs) + gap * (len(figs) + 1); H = max(f.height for f in figs) + 2 * gap +sheet = Image.new("RGBA", (W, H), (255, 0, 255, 255)); x = gap +for f in figs: + sheet.alpha_composite(f, (x, H - gap - f.height)); x += f.width + gap +sheet.convert("RGB").save(out); print(out, sheet.size) diff --git a/art/generated/sprites/scripts/creatures.py b/art/generated/sprites/scripts/creatures.py new file mode 100644 index 0000000..91cefe4 --- /dev/null +++ b/art/generated/sprites/scripts/creatures.py @@ -0,0 +1,91 @@ +# Creature descriptions (from the 10x10 sprites and the bestiary), adapted to the imp's chibi style. +GREEN = {"spider", "mage", "bat", "trader"} +HOVER = {"bat", "gargoyle", "owl", "wyrm", "fairy"} +SIEGE = {"ram", "catapult"} +RETRY = {"dwarf", "revenant", "ram"} + +CREATURES = [ + dict(id="kobold", kind="Kobold", name="Kobold", + desc="A small scaly lizard-folk warrior: short and wiry, big lizard-like head with a short snout, bright green scaly skin, glowing yellow eyes, a toothy eager grin with sharp little teeth, a simple dark-green tunic, clawed bare feet, a short tail. It holds an upright wooden spear with a steel tip in its right hand."), + dict(id="spider", kind="Spider", name="Cave Spider", + desc="A big dungeon spider: a big round body and head in dark violet-purple with lighter purple highlights, a cluster of glowing yellow eyes on the front of the head, eight thick black jointed legs spread around it, small fangs. Low to the ground, compact and menacing, cute-chibi proportions with an oversized head."), + dict(id="mage", kind="Mage", name="Shadow Mage", + desc="A hooded dark sorcerer: a big pointed purple hood that hides most of the oversized head, the face in shadow with only a pale chin and two glowing yellow eyes visible, dark-purple robes with light violet trim down to the feet, a wooden staff with a glowing yellow gem on top held in the right hand."), + dict(id="ogre", kind="Ogre", name="Stone Ogre", + desc="A hulking ogre with skin like rock: broad and hunched, almost no neck, skin of grey stone with darker grey cracks and patches, small glowing yellow eyes, a heavy jaw with an underbite, enormous arms and fists reaching low, short thick legs, a ragged brown loincloth. No weapon - it fights with its fists. Chibi-stocky, as wide as it is tall."), + dict(id="knight", kind="Knight", name="Knight", + desc="A heavily armoured knight of the holy Order: a big polished silver great helm with a dark visor slit, silver shoulder plates and greaves, a blue tabard with a yellow sun emblem on the chest over chainmail, dark boots, a short straight steel sword held upright in the right hand."), + dict(id="archer", kind="Archer", name="Archer", + desc="A hero archer of the Order: a little green hat with a peak, a friendly human face with brown hair, a green tunic with a brown belt, brown trousers and leather boots, a quiver of arrows on the back, holding a wooden bow upright in the left hand."), + dict(id="priest", kind="Priest", name="Priest", + desc="A healing priest of the Order: a human with short brown hair and a kind face, a long plain white robe down to the feet with a golden sun emblem on the chest, a small golden halo ring floating above the head, hands folded in prayer."), + dict(id="dwarf", kind="Dwarf", name="Dwarf Miner", + desc="A stout dwarf miner of the Order: very short and broad, a grey steel helmet, a big bushy orange beard, a skin-coloured nose and eyes peeking out, a brown leather jerkin and belt, brown trousers and boots, a steel pickaxe with a wooden handle over the right shoulder."), + dict(id="paladin", kind="Paladin", name="Paladin of Light", + desc="A big glowing holy champion: a golden helmet with a dark visor, shining white plate armour with golden trim and a golden sun on the chest, golden greaves, broad shoulders, a white holy sword with a golden hilt held upright in the right hand. Bulkier than the other characters but still chibi."), + dict(id="jailer", kind="Jailer", name="Jailer", + desc="A big quiet dungeon jailer: broad-shouldered and heavy, a grey leather executioner's hood with eye holes and glowing yellow eyes, grey-skinned bare muscular arms, a dark-red leather apron over the chest and belly, heavy boots, a coiled iron chain hanging from the right hand."), + dict(id="priestess", kind="Priestess", name="Flame Priestess", + desc="A fire priestess of the dungeon: a woman with fair skin, flame-orange hair rising like fire, a long dark-red robe to the feet with an orange flame emblem on the chest, a small burning fireball floating above her raised right hand."), + dict(id="chainbreaker", kind="Chainbreaker", name="Chainbreaker", + desc="A legendary obsidian demon warrior: a muscular demon of black obsidian with glowing orange-lava cracks across the body, glowing yellow eyes, short curved horns, broken iron shackles with dangling chain links on both wrists, a steel battle axe with a light grey blade in each hand, heavy clawed feet."), + dict(id="bat", kind="Bat", name="Giant Bat", + desc="A big dungeon bat: a round furry dark-purple body with lighter purple highlights, leathery purple wings spread out with black finger bones, big pointed ears, small glowing red eyes, a little fanged mouth, tiny clawed feet hanging down. Hovering in the air, wings spread but kept compact within its own cell."), + dict(id="gargoyle", kind="Gargoyle", name="Chain Gargoyle", + desc="A winged stone gargoyle: a crouching, hunched creature of carved grey stone with darker grey cracks, stone bat wings with pale tips, short curved horns, glowing yellow eyes, a grim mouth with fangs, clawed hands and feet, an iron chain wrapped around one arm. Hovering in the air."), + dict(id="owl", kind="Owl", name="Arcane Owl", + desc="A wise magical owl: a round brown-feathered owl with lighter brown and cream breast feathers, tufted ear feathers, huge glowing yellow eyes with black pupils, a small yellow beak, wings with deep blue magical tips, yellow talons, a judging expression. Hovering in the air with its wings half spread."), + dict(id="wyrm", kind="Wyrm", name="Ember Wyrm", + desc="A small fire dragon: a compact red dragon with dark-red scales on the back, a glowing orange belly, dark-red bat wings, a single white horn, glowing yellow eyes, a curled tail, clawed legs, small embers of fire at its mouth. Hovering in the air."), + dict(id="cogling", kind="Cogling", name="Cogling", + desc="A sooty little goblin tinkerer: tiny and round, burnt-orange sooty skin, big brass goggles with glowing yellow lenses over the eyes, a grey work overall over an orange shirt, small boots, a steel hammer held up in the right hand, a few small brass cogs on the belt."), + dict(id="revenant", kind="Revenant", name="Revenant", + desc="An undead warrior: a gaunt pale corpse with a bony white skull face, hollow eye sockets with glowing green grave-light, tattered grey rags and old rusty armour pieces, thin bony arms and legs, a rusty steel sword held in the right hand."), + dict(id="giant", kind="Giant", name="Giant", + desc="A hero giant of the Order: a big broad human brute with fair skin, a small bald head with a dumb frown and small eyes, a brown leather jerkin over a blue shirt, bare muscular arms, heavy brown boots, a big wooden tree-trunk club resting on the right shoulder. Bulkier than the other characters but still chibi."), + dict(id="fairy", kind="Fairy", name="Fairy Spy", + desc="A tiny glowing fairy informer: a small figure with fair skin, blonde hair and small pointed ears, glowing yellow eyes, a short blue dress, two pairs of translucent pale-blue dragonfly wings, a few golden sparkles below the feet. Hovering in the air."), + dict(id="battlemage", kind="BattleMage", name="Battle Mage", + desc="A fire mage of the Order: a tall pointed blue wizard hat, a human face with a stern look and a short grey beard, long blue robes with white trim and a white sash, a wooden staff in the right hand with a burning orange-yellow flame on top."), + dict(id="ram", kind="Ram", name="Battering Ram", + desc="A wooden siege battering ram, no people visible: a compact wooden frame on four solid wooden wheels with a slanted brown plank roof; under the roof hangs a thick tree trunk with a big grey iron cap on its front end. Brown and dark-brown wood, iron rivets. Chunky toy-like proportions. Show the same five views of the machine (front, front three-quarter turned right, side facing right, back three-quarter, back)."), + dict(id="catapult", kind="Catapult", name="Catapult", + desc="A wooden siege catapult, no people visible: a low rectangular wooden base on four wooden wheels, a wooden throwing arm angled upward with a cup at its tip holding a grey stone boulder, ropes and a crossbeam. Brown and dark-brown wood, iron fittings. Chunky toy-like proportions. Show the same five views of the machine (front, front three-quarter turned right, side facing right, back three-quarter, back)."), + dict(id="cavebear", kind="CaveBear", name="Cave Bear", + desc="A shaggy cave bear: big, round and shaggy, thick dark-brown fur with lighter brown highlights, small round ears, small glowing yellow eyes, a light tan muzzle with a black nose, big paws with claws, standing on all four legs, grumpy. Chunky chibi proportions with a big head."), + dict(id="beetle", kind="Beetle", name="Giant Beetle", + desc="A beetle the size of a barrel: a big round shiny dark-green shell with lighter green highlights and a line down the middle, a dark head with glowing yellow eyes, two curved black mandibles and two short antennae, six short black legs. Low on the ground, chunky and round."), + dict(id="trader", kind="Trader", name="Trader", + desc="A wandering hooded merchant: a deep purple hooded cloak with a friendly human face peeking out, a yellow clasp on the chest, a big brown leather backpack piled high with goods, brown boots, a small glowing lantern held in the right hand."), + dict(id="thief", kind="Thief", name="Thief", + desc="A sneaky thief: a dark charcoal-grey hood and a black mask over the lower face with only the eyes showing, close-fitting dark-grey clothes with a brown belt and soft brown boots, a short steel dagger in the right hand, a bulging tan sack of gold coins slung over the back."), + dict(id="monk", kind="Monk", name="Monk", + desc="A fighting monk of the Order: a shaven bald head, calm face with closed eyes, a simple orange robe with darker orange shading, tied with a yellow rope belt, bare arms and bare fists raised in a ready fighting stance, sandals."), + dict(id="ranger", kind="Ranger", name="Ranger", + desc="A woodsman ranger of the Order: a green hood and green cloak, a human face, a green tunic with a brown leather belt and strap, brown leather trousers and boots, a wooden bow held upright in the left hand, a small iron snare trap hanging from the belt."), + dict(id="inquisitor", kind="Inquisitor", name="Inquisitor", + desc="A frail zealous preacher of the Order: a thin old man with a tall dark-red mitre hat, a pale face with narrowed eyes, long white robes with a red stripe down the middle and red hems, holding open a glowing yellow holy book in front of him."), +] +BY_ID = {c["id"]: c for c in CREATURES} + +TEMPLATE = """The attached image is the style reference: a pixel art sprite sheet of the "Pit Imp" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). + +Draw a DIFFERENT {thing} in exactly the same style: the "{name}". {desc} + +Use exactly the same style as the reference: the same compact chibi proportions (big head, short stocky body, about the same overall size as the imp), the same thick one-pixel near-black outline, the same palette approach (flat colours with one highlight and one shadow tone), the same pixel size (about 32x32 pixels per figure, scaled up with hard square pixels), no anti-aliasing, no gradients, no blur. + +Layout: exactly five figures in one single horizontal row (no second row, no extra figures), the same five views, in the same order (1 front view facing the viewer, 2 front three-quarter view turned to the right, 3 side view facing right, 4 back three-quarter view turned away to the right, 5 back view), evenly spaced, all the same size, {pose}, each figure separated from its neighbours by empty background. + +Background: flat pure {bgname} everywhere, no ground, no shadows, no text, no labels, no frames, no grid lines, no cell borders.""" + + +def prompt_for(cid): + c = BY_ID[cid] + thing = "siege engine" if cid in SIEGE else "creature" + pose = "hovering in the air, idle" if cid in HOVER else ("standing still" if cid in SIEGE else "standing, idle") + bgname = "green (#00FF00)" if cid in GREEN else "magenta (#FF00FF)" + extra = ("\n\nImportant: there must be exactly FIVE figures in ONE row. Do not skip the fourth one: the back three-quarter view, seen from behind at an angle, the face hidden, the body turned away from the viewer towards the upper right, between the side view and the straight back view." + if cid in RETRY else "") + if cid in RETRY and cid in SIEGE: + extra = "\n\nImportant: there must be exactly FIVE drawings of the machine in ONE row, no second row. 1 front view: the iron-capped end of the ram points straight at the viewer. 2 front three-quarter view: the capped end points towards the viewer and to the right. 3 side view: the capped end points to the right. 4 back three-quarter view: the capped end points away from the viewer towards the upper right, we see the rear end of the machine. 5 back view: we see the rear end straight on." + return extra and TEMPLATE.format(thing=thing, name=c["name"], desc=c["desc"], pose=pose, bgname=bgname) + extra or TEMPLATE.format(thing=thing, name=c["name"], desc=c["desc"], pose=pose, bgname=bgname) diff --git a/art/generated/sprites/scripts/extra.txt b/art/generated/sprites/scripts/extra.txt new file mode 100644 index 0000000..c63ed27 --- /dev/null +++ b/art/generated/sprites/scripts/extra.txt @@ -0,0 +1,8 @@ +spider --grid 2x4 --pick 0,1,2,3,3 +ogre --crop 0,0,1,0.5 +chainbreaker --crop 0,0,1,0.5 +bat --crop 0,0,1,0.5 +gargoyle --crop 0,0,1,0.5 +owl --crop 0,0,1,0.5 +cogling --crop 0,0,1,0.5 +cavebear --grid 2x4 --row-counts 4,2 --pick 0,1,2,m5,3 diff --git a/art/generated/sprites/scripts/gen.py b/art/generated/sprites/scripts/gen.py new file mode 100644 index 0000000..5d921ee --- /dev/null +++ b/art/generated/sprites/scripts/gen.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# Generate creature sprite sheets with fal-ai/nano-banana-pro/edit, the imp's lower row as style ref. +import base64, json, os, sys, urllib.request, concurrent.futures as cf + +OUT = "/home/zink/Projects/dungeoncraft/art/generated/sprites" +REF = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "style-ref.png") +KEY = open(os.path.expanduser("~/.config/dungeoncraft/fal-key")).read().strip() + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from creatures import CREATURES, prompt_for + +ref_uri = "data:image/png;base64," + base64.b64encode(open(REF, "rb").read()).decode() + + +def gen(cid, suffix=""): + body = {"prompt": prompt_for(cid), "image_urls": [ref_uri], "aspect_ratio": "21:9", + "resolution": "2K", "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) + url = res["images"][0]["url"] + with urllib.request.urlopen(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) diff --git a/art/generated/sprites/scripts/notes.py b/art/generated/sprites/scripts/notes.py new file mode 100644 index 0000000..27a9c68 --- /dev/null +++ b/art/generated/sprites/scripts/notes.py @@ -0,0 +1,32 @@ +NOTES = { + "kobold": (True, "good - chibi lizard, spear in all views; raw has frame lines (removed by the tool)"), + "spider": (True, "usable - both tries came as 2 rows without a back three-quarter view; the back view is used twice (views 4 and 5 identical)"), + "mage": (True, "good - green key, purple hood and robe survive, glowing eyes, gem staff"), + "ogre": (True, "good - stocky stone ogre; raw has 2 rows (top row used); back three-quarter drawn almost as a side view"), + "knight": (True, "good - great helm, blue tabard with sun, sword"), + "archer": (True, "good - a bit taller than the imp, bow and quiver readable"), + "priest": (True, "good - white robe, sun emblem, floating halo"), + "dwarf": (True, "good - composed from two tries (each had only 4 views): try 1 front/front 3/4/side/back + try 2 back three-quarter"), + "paladin": (True, "good - white/gold armour, big sword; very bright"), + "jailer": (True, "good - hood, grey arms, red apron, chain"), + "priestess": (True, "good - flame hair, red robe, fireball"), + "chainbreaker": (True, "good - obsidian with lava cracks, horns, axes; raw has 2 rows (top row used); dark at 32 px"), + "bat": (True, "good - green key, purple body and wings survive; raw has 2 rows (top row used)"), + "gargoyle": (True, "good - grey stone, wings, chain; raw has 2 rows (top row used)"), + "owl": (True, "good - round chibi owl, blue wing tips; raw has 2 rows (top row used)"), + "wyrm": (True, "good - red dragon, orange belly, white horn; tall and thin because of the hanging tail"), + "cogling": (True, "good - goggles, overall, hammer; raw has 2 rows (top row used)"), + "revenant": (True, "good - second try (first had only 4 views); big skull, green eyes, rusty sword"), + "giant": (True, "good - bald brute with club; drawn chibi, so not much bigger than the others (the game scales it)"), + "fairy": (True, "good - blue dress, pale wings; a few gold sparkles below the feet are part of the sprite, so the bottom row is the sparkles, not the feet"), + "battlemage": (True, "good - blue hat and robes, burning staff, grey beard"), + "ram": (True, "usable - composed from two tries (both 2 rows, views out of order): try 1 front/front 3/4/side/back + try 2 back three-quarter; the side view is wide, the 'back' view of try 1 may show the front end again"), + "catapult": (True, "usable - raw came as 2 rows of 4+3; front/front 3/4/side/back 3/4 from the top row, back view from row 2 (scaled 0.8); noisy at 32 px"), + "cavebear": (True, "good - four-legged bear; raw has 2 rows of 4+2; back three-quarter is a mirrored figure of row 2"), + "beetle": (True, "good - green shell, yellow eyes; raw has 2 rows of 4+2; back three-quarter from row 2 (scaled 0.78)"), + "trader": (True, "good - green key, purple cloak survives, backpack, lantern"), + "thief": (True, "good - dark hood and mask, dagger, tan loot sack"), + "monk": (True, "good - bald, orange robe, fists raised"), + "ranger": (True, "good - green hood and cloak, bow, snare"), + "inquisitor": (True, "good - red mitre, white/red robes, glowing book; looks less frail than described"), +} diff --git a/art/generated/sprites/scripts/process.py b/art/generated/sprites/scripts/process.py new file mode 100644 index 0000000..2495ac9 --- /dev/null +++ b/art/generated/sprites/scripts/process.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# Pixelize all raw sheets to 32 and 64 px and build contact sheets. +import os, subprocess, sys +from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from creatures import CREATURES + +D = "/home/zink/Projects/dungeoncraft/art/generated/sprites" +T = "/home/zink/Projects/dungeoncraft/tools/pixelize_sprite.py" +PY = "/tmp/sprvenv/bin/python" +EXTRA = {} # id -> extra args +for line in open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "extra.txt")) if os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "extra.txt")) else []: + if line.strip(): + k, v = line.split(None, 1) + EXTRA[k] = v.split() + +ids = sys.argv[1:] or [c["id"] for c in CREATURES] +for cid in ids: + raw = f"{D}/{cid}-raw.png" + if not os.path.exists(raw): + print(cid, "missing raw"); continue + for size, colors in ((32, 16), (64, 24)): + cmd = [PY, "-I", T, raw, f"{D}/{cid}", "--views", "5", "--size", str(size), "--colors", str(colors), + "--feet", str(size - 1), "--outline-weight", "0.6"] + EXTRA.get(cid, []) + r = subprocess.run(cmd, capture_output=True, text=True) + print(cid, size, (r.stdout + r.stderr).strip().splitlines()[-1:]) + prev = f"{D}/{cid}-{size}-preview.png" + if os.path.exists(prev): + os.remove(prev) + +# contact sheets: 64 px versions at 2x with labels, 32 px at 2x beside +from PIL import ImageDraw +allids = [c["id"] for c in CREATURES] +for part, chunk in enumerate((allids[:10], allids[10:20], allids[20:])): + W = 320 * 2 + 20 + 160 * 2 + im = Image.new("RGBA", (W, len(chunk) * 140), (96, 92, 104, 255)) + d = ImageDraw.Draw(im) + for i, cid in enumerate(chunk): + y = i * 140 + d.text((2, y), cid, fill=(255, 255, 255, 255)) + for size, x, sc in ((64, 0, 2), (32, 660, 2)): + p = f"{D}/{cid}-{size}.png" + if os.path.exists(p): + s = Image.open(p).convert("RGBA") + s = s.resize((s.width * sc, s.height * sc), Image.NEAREST) + im.alpha_composite(s, (x, y + 12)) + im.save(f"/tmp/sprites-contact-{part}.png") diff --git a/art/generated/sprites/spider-32.png b/art/generated/sprites/spider-32.png new file mode 100644 index 0000000..994e406 Binary files /dev/null and b/art/generated/sprites/spider-32.png differ diff --git a/art/generated/sprites/spider-64.png b/art/generated/sprites/spider-64.png new file mode 100644 index 0000000..626916c Binary files /dev/null and b/art/generated/sprites/spider-64.png differ diff --git a/art/generated/sprites/style-ref.png b/art/generated/sprites/style-ref.png new file mode 100644 index 0000000..9a906d7 Binary files /dev/null and b/art/generated/sprites/style-ref.png differ diff --git a/art/generated/sprites/thief-32.png b/art/generated/sprites/thief-32.png new file mode 100644 index 0000000..a778b06 Binary files /dev/null and b/art/generated/sprites/thief-32.png differ diff --git a/art/generated/sprites/thief-64.png b/art/generated/sprites/thief-64.png new file mode 100644 index 0000000..6ad9777 Binary files /dev/null and b/art/generated/sprites/thief-64.png differ diff --git a/art/generated/sprites/trader-32.png b/art/generated/sprites/trader-32.png new file mode 100644 index 0000000..461b2b5 Binary files /dev/null and b/art/generated/sprites/trader-32.png differ diff --git a/art/generated/sprites/trader-64.png b/art/generated/sprites/trader-64.png new file mode 100644 index 0000000..1028998 Binary files /dev/null and b/art/generated/sprites/trader-64.png differ diff --git a/art/generated/sprites/wyrm-32.png b/art/generated/sprites/wyrm-32.png new file mode 100644 index 0000000..1aaa2ef Binary files /dev/null and b/art/generated/sprites/wyrm-32.png differ diff --git a/art/generated/sprites/wyrm-64.png b/art/generated/sprites/wyrm-64.png new file mode 100644 index 0000000..dd6aec4 Binary files /dev/null and b/art/generated/sprites/wyrm-64.png differ diff --git a/assets/sprites/archer-32.png b/assets/sprites/archer-32.png new file mode 100644 index 0000000..cb4e8ed Binary files /dev/null and b/assets/sprites/archer-32.png differ diff --git a/assets/sprites/archer-64.png b/assets/sprites/archer-64.png new file mode 100644 index 0000000..8d7fca7 Binary files /dev/null and b/assets/sprites/archer-64.png differ diff --git a/assets/sprites/bat-32.png b/assets/sprites/bat-32.png new file mode 100644 index 0000000..be7c67a Binary files /dev/null and b/assets/sprites/bat-32.png differ diff --git a/assets/sprites/bat-64.png b/assets/sprites/bat-64.png new file mode 100644 index 0000000..b7f00f0 Binary files /dev/null and b/assets/sprites/bat-64.png differ diff --git a/assets/sprites/battlemage-32.png b/assets/sprites/battlemage-32.png new file mode 100644 index 0000000..8f3f20b Binary files /dev/null and b/assets/sprites/battlemage-32.png differ diff --git a/assets/sprites/battlemage-64.png b/assets/sprites/battlemage-64.png new file mode 100644 index 0000000..7191528 Binary files /dev/null and b/assets/sprites/battlemage-64.png differ diff --git a/assets/sprites/beetle-32.png b/assets/sprites/beetle-32.png new file mode 100644 index 0000000..1fd2c74 Binary files /dev/null and b/assets/sprites/beetle-32.png differ diff --git a/assets/sprites/beetle-64.png b/assets/sprites/beetle-64.png new file mode 100644 index 0000000..19274bc Binary files /dev/null and b/assets/sprites/beetle-64.png differ diff --git a/assets/sprites/catapult-32.png b/assets/sprites/catapult-32.png new file mode 100644 index 0000000..8ca7c07 Binary files /dev/null and b/assets/sprites/catapult-32.png differ diff --git a/assets/sprites/catapult-64.png b/assets/sprites/catapult-64.png new file mode 100644 index 0000000..998ec15 Binary files /dev/null and b/assets/sprites/catapult-64.png differ diff --git a/assets/sprites/cavebear-32.png b/assets/sprites/cavebear-32.png new file mode 100644 index 0000000..db0021c Binary files /dev/null and b/assets/sprites/cavebear-32.png differ diff --git a/assets/sprites/cavebear-64.png b/assets/sprites/cavebear-64.png new file mode 100644 index 0000000..9809b29 Binary files /dev/null and b/assets/sprites/cavebear-64.png differ diff --git a/assets/sprites/chainbreaker-32.png b/assets/sprites/chainbreaker-32.png new file mode 100644 index 0000000..1542458 Binary files /dev/null and b/assets/sprites/chainbreaker-32.png differ diff --git a/assets/sprites/chainbreaker-64.png b/assets/sprites/chainbreaker-64.png new file mode 100644 index 0000000..0290fe8 Binary files /dev/null and b/assets/sprites/chainbreaker-64.png differ diff --git a/assets/sprites/cogling-32.png b/assets/sprites/cogling-32.png new file mode 100644 index 0000000..c54177c Binary files /dev/null and b/assets/sprites/cogling-32.png differ diff --git a/assets/sprites/cogling-64.png b/assets/sprites/cogling-64.png new file mode 100644 index 0000000..056178e Binary files /dev/null and b/assets/sprites/cogling-64.png differ diff --git a/assets/sprites/dwarf-32.png b/assets/sprites/dwarf-32.png new file mode 100644 index 0000000..2017814 Binary files /dev/null and b/assets/sprites/dwarf-32.png differ diff --git a/assets/sprites/dwarf-64.png b/assets/sprites/dwarf-64.png new file mode 100644 index 0000000..cd27e39 Binary files /dev/null and b/assets/sprites/dwarf-64.png differ diff --git a/assets/sprites/fairy-32.png b/assets/sprites/fairy-32.png new file mode 100644 index 0000000..8ad14bd Binary files /dev/null and b/assets/sprites/fairy-32.png differ diff --git a/assets/sprites/fairy-64.png b/assets/sprites/fairy-64.png new file mode 100644 index 0000000..8b00fad Binary files /dev/null and b/assets/sprites/fairy-64.png differ diff --git a/assets/sprites/gargoyle-32.png b/assets/sprites/gargoyle-32.png new file mode 100644 index 0000000..2c58d00 Binary files /dev/null and b/assets/sprites/gargoyle-32.png differ diff --git a/assets/sprites/gargoyle-64.png b/assets/sprites/gargoyle-64.png new file mode 100644 index 0000000..a6b5aea Binary files /dev/null and b/assets/sprites/gargoyle-64.png differ diff --git a/assets/sprites/giant-32.png b/assets/sprites/giant-32.png new file mode 100644 index 0000000..e8f06f1 Binary files /dev/null and b/assets/sprites/giant-32.png differ diff --git a/assets/sprites/giant-64.png b/assets/sprites/giant-64.png new file mode 100644 index 0000000..bc58eb0 Binary files /dev/null and b/assets/sprites/giant-64.png differ diff --git a/assets/sprites/inquisitor-32.png b/assets/sprites/inquisitor-32.png new file mode 100644 index 0000000..238e7da Binary files /dev/null and b/assets/sprites/inquisitor-32.png differ diff --git a/assets/sprites/inquisitor-64.png b/assets/sprites/inquisitor-64.png new file mode 100644 index 0000000..e161651 Binary files /dev/null and b/assets/sprites/inquisitor-64.png differ diff --git a/assets/sprites/jailer-32.png b/assets/sprites/jailer-32.png new file mode 100644 index 0000000..21732b6 Binary files /dev/null and b/assets/sprites/jailer-32.png differ diff --git a/assets/sprites/jailer-64.png b/assets/sprites/jailer-64.png new file mode 100644 index 0000000..7ccebde Binary files /dev/null and b/assets/sprites/jailer-64.png differ diff --git a/assets/sprites/knight-32.png b/assets/sprites/knight-32.png new file mode 100644 index 0000000..31f5900 Binary files /dev/null and b/assets/sprites/knight-32.png differ diff --git a/assets/sprites/knight-64.png b/assets/sprites/knight-64.png new file mode 100644 index 0000000..6335382 Binary files /dev/null and b/assets/sprites/knight-64.png differ diff --git a/assets/sprites/kobold-32.png b/assets/sprites/kobold-32.png new file mode 100644 index 0000000..c97b9dd Binary files /dev/null and b/assets/sprites/kobold-32.png differ diff --git a/assets/sprites/kobold-64.png b/assets/sprites/kobold-64.png new file mode 100644 index 0000000..8c90a4b Binary files /dev/null and b/assets/sprites/kobold-64.png differ diff --git a/assets/sprites/mage-32.png b/assets/sprites/mage-32.png new file mode 100644 index 0000000..a73dacd Binary files /dev/null and b/assets/sprites/mage-32.png differ diff --git a/assets/sprites/mage-64.png b/assets/sprites/mage-64.png new file mode 100644 index 0000000..973f78d Binary files /dev/null and b/assets/sprites/mage-64.png differ diff --git a/assets/sprites/monk-32.png b/assets/sprites/monk-32.png new file mode 100644 index 0000000..754d91b Binary files /dev/null and b/assets/sprites/monk-32.png differ diff --git a/assets/sprites/monk-64.png b/assets/sprites/monk-64.png new file mode 100644 index 0000000..8baa442 Binary files /dev/null and b/assets/sprites/monk-64.png differ diff --git a/assets/sprites/ogre-32.png b/assets/sprites/ogre-32.png new file mode 100644 index 0000000..b14b0c5 Binary files /dev/null and b/assets/sprites/ogre-32.png differ diff --git a/assets/sprites/ogre-64.png b/assets/sprites/ogre-64.png new file mode 100644 index 0000000..0d1db2b Binary files /dev/null and b/assets/sprites/ogre-64.png differ diff --git a/assets/sprites/owl-32.png b/assets/sprites/owl-32.png new file mode 100644 index 0000000..6f48da5 Binary files /dev/null and b/assets/sprites/owl-32.png differ diff --git a/assets/sprites/owl-64.png b/assets/sprites/owl-64.png new file mode 100644 index 0000000..750f2ff Binary files /dev/null and b/assets/sprites/owl-64.png differ diff --git a/assets/sprites/paladin-32.png b/assets/sprites/paladin-32.png new file mode 100644 index 0000000..f8959b0 Binary files /dev/null and b/assets/sprites/paladin-32.png differ diff --git a/assets/sprites/paladin-64.png b/assets/sprites/paladin-64.png new file mode 100644 index 0000000..1848d55 Binary files /dev/null and b/assets/sprites/paladin-64.png differ diff --git a/assets/sprites/priest-32.png b/assets/sprites/priest-32.png new file mode 100644 index 0000000..5dd6da2 Binary files /dev/null and b/assets/sprites/priest-32.png differ diff --git a/assets/sprites/priest-64.png b/assets/sprites/priest-64.png new file mode 100644 index 0000000..d8c0b3a Binary files /dev/null and b/assets/sprites/priest-64.png differ diff --git a/assets/sprites/priestess-32.png b/assets/sprites/priestess-32.png new file mode 100644 index 0000000..7303f76 Binary files /dev/null and b/assets/sprites/priestess-32.png differ diff --git a/assets/sprites/priestess-64.png b/assets/sprites/priestess-64.png new file mode 100644 index 0000000..8a62bb0 Binary files /dev/null and b/assets/sprites/priestess-64.png differ diff --git a/assets/sprites/ram-32.png b/assets/sprites/ram-32.png new file mode 100644 index 0000000..298fc62 Binary files /dev/null and b/assets/sprites/ram-32.png differ diff --git a/assets/sprites/ram-64.png b/assets/sprites/ram-64.png new file mode 100644 index 0000000..1f7cf3b Binary files /dev/null and b/assets/sprites/ram-64.png differ diff --git a/assets/sprites/ranger-32.png b/assets/sprites/ranger-32.png new file mode 100644 index 0000000..4686c35 Binary files /dev/null and b/assets/sprites/ranger-32.png differ diff --git a/assets/sprites/ranger-64.png b/assets/sprites/ranger-64.png new file mode 100644 index 0000000..64ddb69 Binary files /dev/null and b/assets/sprites/ranger-64.png differ diff --git a/assets/sprites/revenant-32.png b/assets/sprites/revenant-32.png new file mode 100644 index 0000000..8fff61b Binary files /dev/null and b/assets/sprites/revenant-32.png differ diff --git a/assets/sprites/revenant-64.png b/assets/sprites/revenant-64.png new file mode 100644 index 0000000..f1e2e64 Binary files /dev/null and b/assets/sprites/revenant-64.png differ diff --git a/assets/sprites/spider-32.png b/assets/sprites/spider-32.png new file mode 100644 index 0000000..994e406 Binary files /dev/null and b/assets/sprites/spider-32.png differ diff --git a/assets/sprites/spider-64.png b/assets/sprites/spider-64.png new file mode 100644 index 0000000..626916c Binary files /dev/null and b/assets/sprites/spider-64.png differ diff --git a/assets/sprites/thief-32.png b/assets/sprites/thief-32.png new file mode 100644 index 0000000..a778b06 Binary files /dev/null and b/assets/sprites/thief-32.png differ diff --git a/assets/sprites/thief-64.png b/assets/sprites/thief-64.png new file mode 100644 index 0000000..6ad9777 Binary files /dev/null and b/assets/sprites/thief-64.png differ diff --git a/assets/sprites/trader-32.png b/assets/sprites/trader-32.png new file mode 100644 index 0000000..461b2b5 Binary files /dev/null and b/assets/sprites/trader-32.png differ diff --git a/assets/sprites/trader-64.png b/assets/sprites/trader-64.png new file mode 100644 index 0000000..1028998 Binary files /dev/null and b/assets/sprites/trader-64.png differ diff --git a/assets/sprites/wyrm-32.png b/assets/sprites/wyrm-32.png new file mode 100644 index 0000000..1aaa2ef Binary files /dev/null and b/assets/sprites/wyrm-32.png differ diff --git a/assets/sprites/wyrm-64.png b/assets/sprites/wyrm-64.png new file mode 100644 index 0000000..dd6aec4 Binary files /dev/null and b/assets/sprites/wyrm-64.png differ diff --git a/src/gfx.rs b/src/gfx.rs index 4ff0565..e6162ee 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1735,35 +1735,98 @@ const SPR_CELL: u32 = 12; const SPR_K: u32 = 3; pub const ACELL: u32 = SPR_CELL * SPR_K; -// Detailed sprites (generated, see art/generated/) in their own texture, used instead of the -// 10x10 ones in the high-res graphics style (render3d::billboards). Two levels of detail from -// the same pictures: 64x64 when the sprite gets enough screen pixels (close), 32x32 otherwise, -// so neither loses pixels nor flickers. Sheets: transparent, the feet on the bottom row; -// `idle*`: the five views side by side (VIEWS); `hack*`: the digging animation, HACK_FRAMES -// frames side by side, one row per view. No gear yet. +// Detailed sprites (generated, see art/generated/imp-sprite and art/generated/sprites) in their +// own texture, used instead of the 10x10 ones in the high-res graphics style +// (render3d::billboards). Two levels of detail from the same pictures: 64x64 when the sprite gets +// enough screen pixels (close), 32x32 otherwise, so neither loses pixels nor flickers. Sheets: +// transparent, the feet on the bottom row; idle: the five views side by side (VIEWS); hack (the +// imp): the digging animation, HACK_FRAMES frames side by side, one row per view. No gear yet. struct HdSprite { kind: EK, idle: &'static [u8], - hack: &'static [u8], idle_32: &'static [u8], - hack_32: &'static [u8], + hack: Option<(&'static [u8], &'static [u8])>, // 64x64 and 32x32 } -const HD_SPRITES: [HdSprite; 1] = [HdSprite { - kind: EK::Worker, - idle: include_bytes!("../assets/sprites/imp-64.png"), - hack: include_bytes!("../assets/sprites/imp-hack-64.png"), - idle_32: include_bytes!("../assets/sprites/imp-32.png"), - hack_32: include_bytes!("../assets/sprites/imp-hack-32.png"), -}]; +macro_rules! hd_sprite { + ($kind:expr, $name:literal) => { + HdSprite { + kind: $kind, + idle: include_bytes!(concat!("../assets/sprites/", $name, "-64.png")), + idle_32: include_bytes!(concat!("../assets/sprites/", $name, "-32.png")), + hack: None, + } + }; +} +const HD_SPRITES: [HdSprite; 31] = [ + HdSprite { + kind: EK::Worker, + idle: include_bytes!("../assets/sprites/imp-64.png"), + idle_32: include_bytes!("../assets/sprites/imp-32.png"), + hack: Some((include_bytes!("../assets/sprites/imp-hack-64.png"), include_bytes!("../assets/sprites/imp-hack-32.png"))), + }, + hd_sprite!(EK::Kobold, "kobold"), + hd_sprite!(EK::Spider, "spider"), + hd_sprite!(EK::Mage, "mage"), + hd_sprite!(EK::Ogre, "ogre"), + hd_sprite!(EK::Knight, "knight"), + hd_sprite!(EK::Archer, "archer"), + hd_sprite!(EK::Priest, "priest"), + hd_sprite!(EK::Dwarf, "dwarf"), + hd_sprite!(EK::Paladin, "paladin"), + hd_sprite!(EK::Jailer, "jailer"), + hd_sprite!(EK::Priestess, "priestess"), + hd_sprite!(EK::Chainbreaker, "chainbreaker"), + hd_sprite!(EK::Bat, "bat"), + hd_sprite!(EK::Gargoyle, "gargoyle"), + hd_sprite!(EK::Owl, "owl"), + hd_sprite!(EK::Wyrm, "wyrm"), + hd_sprite!(EK::Cogling, "cogling"), + hd_sprite!(EK::Revenant, "revenant"), + hd_sprite!(EK::Giant, "giant"), + hd_sprite!(EK::Fairy, "fairy"), + hd_sprite!(EK::BattleMage, "battlemage"), + hd_sprite!(EK::Ram, "ram"), + hd_sprite!(EK::Catapult, "catapult"), + hd_sprite!(EK::CaveBear, "cavebear"), + hd_sprite!(EK::Beetle, "beetle"), + hd_sprite!(EK::Trader, "trader"), + hd_sprite!(EK::Thief, "thief"), + hd_sprite!(EK::Monk, "monk"), + hd_sprite!(EK::Ranger, "ranger"), + hd_sprite!(EK::Inquisitor, "inquisitor"), +]; pub const HACK_FRAMES: u32 = 4; // a detailed sprite's cell: the same shape as a 10x10 sprite's 12x12 cell (6 texels per pixel), // so the billboard is the same; the 64x64 picture sits where the 10x10 sprite would pub const HD_CELL: u32 = SPR_CELL * 6; -// columns per detailed sprite and detail level: idle and the hack frames, each without and with -// the sack; then the same for the 32x32 level +// columns of a sprite with the digging animation, per detail level: idle and the hack frames, +// each without and with the sack; a sprite without it has one column per level const HD_COLS: u32 = (1 + HACK_FRAMES) * 2; -pub const HD_TEX_W: f32 = (HD_SPRITES.len() as u32 * HD_COLS * 2 * HD_CELL) as f32; -pub const HD_TEX_H: f32 = (VIEWS * 2 * HD_CELL) as f32; +// the texture: the sprites' columns one after another (a sprite's own columns: the 64x64 level, +// then the 32x32 one), in bands of HD_BAND columns (a sprite never straddles two bands), every +// band VIEWS * 2 rows (a view, its white silhouette) +const HD_BAND: u32 = 40; +const fn hd_width(j: usize) -> u32 { + if HD_SPRITES[j].hack.is_some() { HD_COLS * 2 } else { 2 } +} +const fn hd_start(j: usize) -> u32 { + let mut p = 0; + let mut i = 0; + while i < j { + p += hd_width(i); + i += 1; + } + p +} +const HD_BANDS: u32 = (hd_start(HD_SPRITES.len()) + HD_BAND - 1) / HD_BAND; +pub const HD_TEX_W: f32 = (HD_BAND * HD_CELL) as f32; +pub const HD_TEX_H: f32 = (HD_BANDS * VIEWS * 2 * HD_CELL) as f32; + +// where column `c` of sprite j lies: (texture column, first row of its band) +fn hd_place(j: usize, c: u32) -> (u32, u32) { + let p = hd_start(j) + c; + (p % HD_BAND, p / HD_BAND * VIEWS * 2) +} // kinds that carry treasure in a sack on the back (instead of the coin sign over the head); // their 10x10 sprite with the sack gets an own atlas column after SPRITE_KINDS @@ -2583,18 +2646,23 @@ fn gen_hd_sprites() -> Image { for (j, hd) in HD_SPRITES.iter().enumerate() { // (idle, hack, picture size, cell grid, texels per pixel, sack, where the sack shows from the front) let levels = [ - (png(hd.idle), png(hd.hack), 64, HD_CELL, 1, &SACK_64[..], (0.7, -0.65)), - (png(hd.idle_32), png(hd.hack_32), 32, HD_CELL / 2, 2, &SACK_32[..], (0.7, -0.65)), + (png(hd.idle), hd.hack.map(|h| png(h.0)), 64, HD_CELL, 1, &SACK_64[..], (0.7, -0.65)), + (png(hd.idle_32), hd.hack.map(|h| png(h.1)), 32, HD_CELL / 2, 2, &SACK_32[..], (0.7, -0.65)), ]; for (l, (idle, hack, n, size, k, sack, dx)) in levels.iter().enumerate() { for view in 0..VIEWS { + let Some(hack) = hack else { + let (col, row) = hd_place(j, l as u32); + put_cell(&mut img, col, row + view * 2, &hd_cell(idle, view * n, 0, *n, *size), *k, HD_CELL); + continue; + }; for f in 0..1 + HACK_FRAMES { let g = if f == 0 { hd_cell(idle, view * n, 0, *n, *size) } else { hd_cell(hack, (f - 1) * n, view * n, *n, *size) }; - let col = (j as u32 * 2 + l as u32) * HD_COLS + f * 2; - put_cell(&mut img, col, view * 2, &g, *k, HD_CELL); + let (col, row) = hd_place(j, l as u32 * HD_COLS + f * 2); + put_cell(&mut img, col, row + view * 2, &g, *k, HD_CELL); let mut s = g; add_sack(&mut s, view, sack, *dx); - put_cell(&mut img, col + 1, view * 2, &s, *k, HD_CELL); + put_cell(&mut img, col + 1, row + view * 2, &s, *k, HD_CELL); } } } @@ -2766,10 +2834,16 @@ impl Gfx { // (`hack` None) or a frame of the digging animation, with or without the sack; `fine`: the // 64x64 level, else the 32x32 one pub fn hd_cell_view(k: EK, white: bool, view: u32, hack: Option, sack: bool, fine: bool) -> Option { - let j = HD_SPRITES.iter().position(|h| h.kind == k)? as u32; - let f = hack.map_or(0, |f| 1 + f % HACK_FRAMES); - let col = (j * 2 + !fine as u32) * HD_COLS + f * 2 + sack as u32; - let row = view.min(VIEWS - 1) * 2 + white as u32; + let j = HD_SPRITES.iter().position(|h| h.kind == k)?; + // sprites without the digging animation (and without a sack) show their idle picture + let c = if HD_SPRITES[j].hack.is_some() { + let f = hack.map_or(0, |f| 1 + f % HACK_FRAMES); + (!fine as u32) * HD_COLS + f * 2 + sack as u32 + } else { + !fine as u32 + }; + let (col, band) = hd_place(j, c); + let row = band + view.min(VIEWS - 1) * 2 + white as u32; Some(Rect::new((col * HD_CELL) as f32, (row * HD_CELL) as f32, HD_CELL as f32, HD_CELL as f32)) } @@ -3190,7 +3264,19 @@ mod sprite_tests { } } } - assert!(Gfx::hd_cell_view(EK::Knight, false, 0, None, false, true).is_none()); + // every kind has a detailed sprite, none straddles two bands of the texture + for &k in SPRITE_KINDS.iter() { + for fine in [true, false] { + for view in 0..VIEWS { + let r = Gfx::hd_cell_view(k, false, view, None, false, fine).expect("a detailed sprite for every kind"); + assert!(opaque(&img, r) > 100, "{k:?} view {view} {fine}"); + assert!(r.x + r.w <= HD_TEX_W && r.y + r.h <= HD_TEX_H); + } + } + } + for j in 0..HD_SPRITES.len() { + assert_eq!(hd_start(j) / HD_BAND, (hd_start(j) + hd_width(j) - 1) / HD_BAND, "sprite {j} straddles two bands"); + } } #[test] diff --git a/tools/pixelize_sprite.py b/tools/pixelize_sprite.py index e2f69b2..35e4969 100644 --- a/tools/pixelize_sprite.py +++ b/tools/pixelize_sprite.py @@ -7,6 +7,8 @@ Usage: pixelize_sprite.py raw.png out_prefix [--grid ROWSxCOLS | --views N] --size 32 [--crop x0,y0,x1,y1] [--colors 16] [--step S] [--feet Y] [--palette sheet.png] [--add-colors R,G,B;R,G,B] [--measure] + [--outline-weight W] [--bg auto|magenta|R,G,B] [--split auto|cells] + [--row-counts 4,3] [--pick 0,1,2,m5,3] [--no-despeckle] --grid / --views layout of the figures in the raw sheet (--views N = one row of N) --crop fractions of the image (0..1) to use only a part of it @@ -19,6 +21,16 @@ Usage: (frames of one character keep exactly the same colours) --add-colors extra palette colours (e.g. the light iron of a tool the reference lacks) --outline-weight weight of near-black pixels when sampling (below 1: thin details survive) +--bg background key: auto (default; the colours of the sheet's border, only + colours close to them are removed, so purple figures on a green sheet + survive), magenta (the old fixed magenta/purple test) or R,G,B +--split auto (default: figures found by the empty gaps between them, falling back to + equal cells) or cells (equal cells of the grid) +--no-despeckle keep single pixels without any neighbour (removed by default) +--row-counts figures per row when the rows differ (e.g. 4,3 with --grid 2x4) +--pick build the output row from these figures (indices in reading order, an "m" + in front mirrors it), e.g. 0,1,2,m5,3; the default step uses only these +Frame or grid lines between cells (long straight lines in one flat colour) are removed. Writes out_prefix-.png (cells in the grid's order, row by row; transparent) and out_prefix--preview.png (scaled up 8x on a grey background for viewing). Needs Pillow. @@ -31,28 +43,328 @@ from PIL import Image def is_background(p): + """The old fixed test: magenta and its darker/greyer purple variants. Only used with + --bg magenta (it also removes purple and pink figures).""" r, g, b = p[:3] - # magenta and its darker/greyer purple variants (sheet background and frame lines) magenta = r > g + 50 and b > g + 50 frame = abs(r - b) < 40 and r > g + 12 and b > g + 12 and r > 55 return magenta or frame -def figure_boxes(img, rows, cols): +def _dist2(p, q): + return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2 + + +def _seg_dist2(p, a, b): + """Squared distance of colour p to the segment a-b (blends of a and b lie on it).""" + d = [b[i] - a[i] for i in range(3)] + dd = sum(v * v for v in d) or 1 + t = max(0.0, min(1.0, sum((p[i] - a[i]) * d[i] for i in range(3)) / dd)) + return sum((p[i] - a[i] - t * d[i]) ** 2 for i in range(3)) + + +def background_colours(img): + """The sheet's background colours, sampled from the image border (a few px inside, so a + dark frame around the sheet does not count): every bright colour that covers a good part + of the border. Usually one (flat magenta or green), sometimes two (cells in two shades).""" w, h = img.size px = img.load() - mask = [[not is_background(px[x, y]) for x in range(w)] for y in range(h)] + samples = [] + for d in (2, 6, 12): + samples += [px[x, min(d, h - 1)] for x in range(0, w, 2)] + samples += [px[x, max(h - 1 - d, 0)] for x in range(0, w, 2)] + samples += [px[min(d, w - 1), y] for y in range(0, h, 2)] + samples += [px[max(w - 1 - d, 0), y] for y in range(0, h, 2)] + q = Counter((p[0] // 32, p[1] // 32, p[2] // 32) for p in samples) + bgs = [] + for k, n in q.most_common(): + if n < 0.08 * len(samples): + break + ps = [p for p in samples if (p[0] // 32, p[1] // 32, p[2] // 32) == k] + m = tuple(sum(p[i] for p in ps) / len(ps) for i in range(3)) + if max(m) >= 120: # dark border colours are frames or figures, not the background + bgs.append(m) + return bgs or [(255.0, 0.0, 255.0)] + + +BG_COLOURS = [] + + +def bg_tinted(p): + """True if p has the background's hue (e.g. magenta: red and blue clearly above green): + blends of the key colour with thin figure parts, which must not become a palette colour.""" + for b in BG_COLOURS: + on = [p[i] for i in range(3) if b[i] >= 128] + off = [p[i] for i in range(3) if b[i] < 128] + if on and off and min(on) > max(off) + 60: + return True + return False + + +def background_mask(img, mode="auto", tol=70): + """True where the pixel is background. mode: "auto" (colours from the border, only colours + near them are keyed, so purple figures on green survive), "magenta" (the old fixed test) + or "R,G,B" (an explicit key colour). Thin frame lines between cells are removed too.""" + w, h = img.size + px = img.load() + data = list(img.get_flattened_data()) + if mode == "magenta": + bg = [is_background(p) for p in data] + else: + bgs = background_colours(img) if mode == "auto" else [tuple(float(v) for v in mode.split(","))] + BG_COLOURS[:] = bgs + t2 = tol * tol + cache = {} + bg = [] + for p in data: + v = cache.get(p) + if v is None: + # the background or a blend of it with the dark outline (anti-aliased edges; + # only the lighter half, the darker half belongs to the outline) + v = cache[p] = any(_dist2(p, b) < t2 or (_seg_dist2(p, b, OUTLINE) < 30 * 30 + and _dist2(p, b) < _dist2(p, OUTLINE)) + for b in bgs) + bg.append(v) + mask = [bg[y * w:(y + 1) * w] for y in range(h)] + remove_frame_lines(px, mask, w, h, [] if mode == "magenta" else bgs) + return mask + + +OUTLINE = (16, 12, 20) + + +def remove_frame_lines(px, mask, w, h, bgs=()): + """Frame/grid lines: a column (row) where almost every pixel is one uniform colour that is + not background, the figures never cover that much of a line in one flat colour. Those + pixels, similar ones and blends with the background up to 8 px beside them become + background.""" + def lines(n, m, get, isbg): + found = [] + for i in range(n): + fg = [get(i, j) for j in range(m) if not isbg(i, j)] + if len(fg) < 0.97 * m: + continue + med = sorted(fg, key=sum)[len(fg) // 2] + if sum(1 for p in fg if _dist2(p, med) < 45 * 45) >= 0.97 * m: + found.append((i, med)) + return found + + cols = lines(w, h, lambda x, y: px[x, y], lambda x, y: mask[y][x]) + rows = lines(h, w, lambda y, x: px[x, y], lambda y, x: mask[y][x]) + def frameish(p, med): + return _dist2(p, med) < 70 * 70 or any(_seg_dist2(p, b, med) < 35 * 35 for b in bgs) + + for x, med in cols: + for xx in range(max(0, x - 8), min(w, x + 9)): + for y in range(h): + if frameish(px[xx, y], med): + mask[y][xx] = True + for y, med in rows: + for yy in range(max(0, y - 8), min(h, y + 9)): + for x in range(w): + if frameish(px[x, yy], med): + mask[yy][x] = True + + +def split_runs(occ, lo, hi, n, min_gap): + """Split [lo, hi) into n figure spans from an occupancy profile: runs of occupied + positions, the closest neighbours merged until n remain. None if fewer than n runs.""" + runs, start = [], None + for i in range(lo, hi): + if occ[i] and start is None: + start = i + elif not occ[i] and start is not None: + runs.append([start, i]) + start = None + if start is not None: + runs.append([start, hi]) + # glue runs separated by tiny gaps (a detached spear tip, a sparkle) + merged = [] + for r in runs: + if merged and r[0] - merged[-1][1] < min_gap: + merged[-1][1] = r[1] + else: + merged.append(r) + # drop specks far too small to be a figure + if merged: + big = max(r[1] - r[0] for r in merged) + merged = [r for r in merged if r[1] - r[0] >= 0.08 * big] + while len(merged) > n: + # merge the pair with the smallest gap + i = min(range(len(merged) - 1), key=lambda k: merged[k + 1][0] - merged[k][1]) + merged[i] = [merged[i][0], merged[i + 1][1]] + del merged[i + 1] + return merged if len(merged) == n else None + + +def trim_slivers(mask, x0, x1, y0, y1, min_gap): + """In equal cells a neighbour's spear tip or wing edge can reach over the cell border: + small runs of columns at the cell's left or right edge, separated from the figure by a gap, + are cleared.""" + occ = [sum(mask[y][x] for y in range(y0, y1)) > 3 for x in range(x0, x1)] + runs, start = [], None + for i, o in enumerate(occ + [False]): + if o and start is None: + start = i + elif not o and start is not None: + if runs and start - runs[-1][1] < min_gap: + runs[-1][1] = i + else: + runs.append([start, i]) + start = None + if len(runs) < 2: + return + big = max(r[1] - r[0] for r in runs) + for r in (runs[0], runs[-1]): + edge = r[0] == 0 or r[1] == len(occ) + if edge and r[1] - r[0] < 0.25 * big: + for x in range(x0 + r[0], x0 + r[1]): + for y in range(y0, y1): + mask[y][x] = False + + +def components(mask, x0, x1, y0, y1, n, f=4): + """Find n figures in a band by connected parts (on a grid f times coarser): the n biggest + parts are the figures, smaller parts (a detached halo, a spear tip) join the nearest + figure, specks are dropped. Returns [(box, test(x, y))] or None.""" + gw, gh = (x1 - x0 + f - 1) // f, (y1 - y0 + f - 1) // f + occ = [[False] * gw for _ in range(gh)] + for y in range(y0, y1): + row = mask[y] + orow = occ[(y - y0) // f] + for x in range(x0, x1): + if row[x]: + orow[(x - x0) // f] = True + lab = [[0] * gw for _ in range(gh)] + parts = [] # [count, gx0, gy0, gx1, gy1] + for sy in range(gh): + for sx in range(gw): + if occ[sy][sx] and not lab[sy][sx]: + k = len(parts) + 1 + lab[sy][sx] = k + stack, part = [(sx, sy)], [0, sx, sy, sx + 1, sy + 1] + while stack: + cx, cy = stack.pop() + part[0] += 1 + part[1], part[2] = min(part[1], cx), min(part[2], cy) + part[3], part[4] = max(part[3], cx + 1), max(part[4], cy + 1) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + nx, ny = cx + dx, cy + dy + if 0 <= nx < gw and 0 <= ny < gh and occ[ny][nx] and not lab[ny][nx]: + lab[ny][nx] = k + stack.append((nx, ny)) + parts.append(part) + if len(parts) < n: + return None + order = sorted(range(len(parts)), key=lambda i: -parts[i][0]) + big = order[:n] + # the n-th biggest part must be a real figure, not a stray piece of a merged pair + if parts[big[-1]][0] < 0.25 * parts[big[0]][0]: + return None + owner = {i + 1: i for i in big} + for i in order[n:]: + if parts[i][0] < 0.01 * parts[big[0]][0]: + continue + p = parts[i] + cx, cy = (p[1] + p[3]) / 2, (p[2] + p[4]) / 2 + + def gap(j): + q = parts[j] + return max(q[1] - cx, cx - q[3], 0) + max(q[2] - cy, cy - q[4], 0) + owner[i + 1] = min(big, key=gap) + figs = [] + for j in sorted(big, key=lambda j: parts[j][1]): + mine = {k for k, v in owner.items() if v == j} + ps_ = [parts[k - 1] for k in mine] + box = (x0 + min(p[1] for p in ps_) * f, y0 + min(p[2] for p in ps_) * f, + min(x1, x0 + max(p[3] for p in ps_) * f), min(y1, y0 + max(p[4] for p in ps_) * f)) + + def test(x, y, mine=mine): + return x0 <= x < x1 and y0 <= y < y1 and mask[y][x] and lab[(y - y0) // f][(x - x0) // f] in mine + figs.append((box, test)) + return figs + + +def valley_split(cnt, n, min_gap): + """Figures that touch (a pickaxe reaching into the neighbour): split the widest run of + occupied columns at its thinnest column (in its middle part) until there are n runs.""" + runs, start = [], None + for i, c in enumerate(cnt + [0]): + if c > 3 and start is None: + start = i + elif c <= 3 and start is not None: + if runs and start - runs[-1][1] < min_gap: + runs[-1][1] = i + else: + runs.append([start, i]) + start = None + if not runs or len(runs) > n: + return None + while len(runs) < n: + k = max(range(len(runs)), key=lambda i: runs[i][1] - runs[i][0]) + a, b = runs[k] + lo, hi = a + (b - a) // 5, b - (b - a) // 5 + cut = min(range(lo, hi), key=lambda x: cnt[x]) + runs[k:k + 1] = [[a, cut], [cut, b]] + return [tuple(r) for r in runs] + + +def tight_box(mask, x0, y0, x1, y1, test=None): + test = test or (lambda x, y: mask[y][x]) + xs = [x for x in range(x0, x1) if sum(test(x, y) for y in range(y0, y1)) > 3] + ys = [y for y in range(y0, y1) if sum(test(x, y) for x in range(x0, x1)) > 3] + return (min(xs), min(ys), max(xs) + 1, max(ys) + 1) + + +FIGURE_TESTS = [] # per box: None (any figure pixel) or a test(x, y) for that figure only + + +def figure_boxes(img, rows, cols, bg="auto", split="auto", row_counts=None): + """Background mask and one box per figure. auto split: by the empty columns between the + figures, else by connected parts (figures whose tools overlap in x), else equal cells.""" + w, h = img.size + mask = [[not v for v in row] for row in background_mask(img, bg)] boxes = [] + FIGURE_TESTS[:] = [] for r in range(rows): y0, y1 = r * h // rows, (r + 1) * h // rows - for c in range(cols): - x0, x1 = c * w // cols, (c + 1) * w // cols - xs = [x for x in range(x0, x1) if sum(mask[y][x] for y in range(y0, y1)) > 3] - ys = [y for y in range(y0, y1) if sum(mask[y][x] for x in range(x0, x1)) > 3] - boxes.append((min(xs), min(ys), max(xs) + 1, max(ys) + 1)) + n = row_counts[r] if row_counts else cols + spans = None + if split == "auto": + occ = [sum(mask[y][x] for y in range(y0, y1)) > 3 for x in range(w)] + spans = split_runs(occ, 0, w, n, max(3, w // 200)) + if spans is None: + figs = components(mask, 0, w, y0, y1, n) + if figs: + for box, test in figs: + boxes.append(tight_box(mask, *box, test)) + FIGURE_TESTS.append(test) + continue + spans = valley_split([sum(mask[y][x] for y in range(y0, y1)) for x in range(w)], + n, max(3, w // 200)) + if spans is None: + spans = [(c * w // n, (c + 1) * w // n) for c in range(n)] + for (x0, x1) in spans: + trim_slivers(mask, x0, x1, y0, y1, max(3, w // 200)) + boxes.append(tight_box(mask, x0, y0, x1, y1)) + FIGURE_TESTS.append(None) return mask, boxes +def despeckle(grid): + """Remove single opaque pixels with no opaque 8-neighbour (stray sparkles, key leftovers).""" + n = len(grid) + out = [row[:] for row in grid] + for y in range(n): + for x in range(n): + if grid[y][x] and not any( + 0 <= y + dy < n and 0 <= x + dx < n and grid[y + dy][x + dx] + for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dy or dx): + out[y][x] = None + return out + + def main(): ap = argparse.ArgumentParser() ap.add_argument("raw") @@ -68,6 +380,11 @@ def main(): ap.add_argument("--add-colors", default="") ap.add_argument("--outline-weight", type=float, default=1.0) ap.add_argument("--measure", action="store_true") + ap.add_argument("--bg", default="auto") + ap.add_argument("--split", default="auto", choices=["auto", "cells"]) + ap.add_argument("--no-despeckle", action="store_true") + ap.add_argument("--row-counts", default=None) + ap.add_argument("--pick", default=None) a = ap.parse_args() rows, cols = (int(v) for v in a.grid.split("x")) if a.grid else (1, a.views) feet = a.size - 1 if a.feet is None else a.feet @@ -77,17 +394,20 @@ def main(): f = [float(v) for v in a.crop.split(",")] w, h = img.size img = img.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h))) - mask, boxes = figure_boxes(img, rows, cols) + row_counts = [int(v) for v in a.row_counts.split(",")] if a.row_counts else None + mask, boxes = figure_boxes(img, rows, cols, a.bg, a.split, row_counts) if a.measure: for i, (x0, y0, x1, y1) in enumerate(boxes): - print(f"figure {i} (row {i // cols}, column {i % cols}): {x1 - x0} x {y1 - y0} source pixels") + print(f"figure {i}: x {x0}-{x1}, y {y0}-{y1}: {x1 - x0} x {y1 - y0} source pixels") return px = img.load() # default: one scale for all figures, the tallest or widest fills the cell - step = a.step or max(max(b[3] - b[1], b[2] - b[0]) for b in boxes) / a.size + used_boxes = [boxes[int(t.lstrip("m"))] for t in a.pick.split(",")] if a.pick else boxes + step = a.step or max(max(b[3] - b[1], b[2] - b[0]) for b in used_boxes) / a.size grids = [] - for (x0, y0, x1, y1) in boxes: + for i, (x0, y0, x1, y1) in enumerate(boxes): + inside = FIGURE_TESTS[i] or (lambda x, y: mask[y][x]) gw, gh = round((x1 - x0) / step), round((y1 - y0) / step) grid = [[None] * a.size for _ in range(a.size)] ox, oy = (a.size - gw) // 2, feet + 1 - gh # centred, the feet on the feet row @@ -100,18 +420,27 @@ def main(): for sx in range(4): x = int(cx0 + (sx + 0.5) * step / 4) y = int(cy0 + (sy + 0.5) * step / 4) - if x >= img.size[0] or y >= img.size[1] or not mask[y][x]: + if x >= img.size[0] or y >= img.size[1] or not inside(x, y): bg += 1 else: r, g, b = px[x, y] + if bg_tinted((r, g, b)): + continue # a blend with the background: no vote at all fg += 1 # the outline counts a little less, so thin coloured parts # (a tool's iron, a handle) are not swallowed by their outline votes[(r // 8 * 8, g // 8 * 8, b // 8 * 8)] += a.outline_weight if r + g + b < 90 else 1 if votes and fg >= bg and 0 <= gy + oy < a.size and 0 <= gx + ox < a.size: grid[gy + oy][gx + ox] = votes.most_common(1)[0][0] - grids.append(grid) + grids.append(grid if a.no_despeckle else despeckle(grid)) + if a.pick: + # a new single row from chosen figures; "m" in front mirrors the figure + picked = [] + for t in a.pick.split(","): + g = grids[int(t.lstrip("m"))] + picked.append([row[::-1] for row in g] if t.startswith("m") else g) + grids, rows, cols = picked, 1, len(picked) used = [c for g in grids for row in g for c in row if c] counts = Counter(used) dist = lambda p, q: sum((x - y) ** 2 for x, y in zip(p, q)) ** 0.5