diff --git a/.gitignore b/.gitignore index 02f9b16..367d4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ play/ dist/ art/generated/creature-previews/ art/generated/sprites/*-raw*.png +art/generated/sprites/anim/*-raw*.png +art/generated/sprites/scripts/__pycache__/ diff --git a/art/generated/sprites/anim/PROMPTS.md b/art/generated/sprites/anim/PROMPTS.md new file mode 100644 index 0000000..c7cfcbe --- /dev/null +++ b/art/generated/sprites/anim/PROMPTS.md @@ -0,0 +1,151 @@ +# Creature animations (pilot: knight, kobold) + +Generated on 2026-11-07 with `fal-ai/nano-banana-pro/edit` (`resolution: 2K`, `num_images: 1`, +`output_format: png`), following the imp's digging animation (`../../imp-sprite/PROMPTS.md`). + +Every sheet has the layout of `imp-hack-*.png`: 4 frames across, 5 views down (front, front +three-quarter turned right, side facing right, back three-quarter turned away to the right, +back), transparent, the feet on the bottom row of every cell, the SAME body scale, feet row and +palette as the creature's idle sheet (`../-32.png` / `-64.png`), so frames and idle line up. + +| file | size | +| --- | --- | +| `-walk-32.png`, `-attack-32.png` | 128x160 | +| `-walk-64.png`, `-attack-64.png` | 256x320 | +| `---preview.png` | frames scaled up (8x / 4x), the idle view in the right column, dotted feet row | + +`walk`: contact, passing, contact (other leg), passing; the creature walks in place (the game +moves it). `attack`: wind-up, strike, impact, recover; the feet stay in place. + +## Script + +`../scripts/make_anim.py` (Pillow venv: `/tmp/sprvenv/bin/python -I`; reads the key from +`~/.config/dungeoncraft/fal-key`, never prints it): + +```bash +S=art/generated/sprites/scripts/make_anim.py +python $S gen [front|back|both] [--suffix -2] [--strict] [--front-src F] [--print] +python $S pix [--front F] [--back B] [--view V=SPEC ...] [--add-colors "R,G,B;..."] +``` + +### gen + +1. **front request** (`--front-raw.png`, `aspect_ratio 4:5`): reference = the idle + raw (`../-raw.png`, the `--crop` of `../scripts/extra.txt` if any) at half size with the + background (and any frame lines, e.g. the kobold's raw) repainted in flat key colour. Asks for + the full 5x4 grid; rows 1-3 are used, rows 4-5 sometimes too (see below). +2. **back request** (`--back-raw.png`, `aspect_ratio 16:9`): references = the same + idle reference + rows 1-3 of the front raw (`--front-src` picks another front raw). 2x4 grid. + `--strict` adds a sentence that all eight figures, the strike frames included, are seen from + behind (no visor/face/emblem) and that the back three-quarter row is turned clearly right. + +Prompts: built by `prompt_front` / `prompt_back` from the creature's idle description +(`creatures.py`), the per-creature `WEAPON` and `ATTACK` texts and the generic `WALK_*` frame +texts in `make_anim.py`. A batch for all creatures needs a `WEAPON` and an `ATTACK` entry for each +one (the walk text is generic). `--print` shows the prompts. Example (kobold attack, front): + +```text +The attached image is a pixel art sprite sheet of the "Kobold" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). A small scaly lizard-folk warrior: ... It holds an upright wooden spear with a steel tip in its right hand. + +Create a new pixel art sprite sheet of exactly the same character - same proportions, colours, outline, pixel size, clothes and equipment - showing a spear thrust: the kobold holds the spear level with both hands and stabs it forward at an enemy in front of it. + +Layout: a grid of 5 rows and 4 columns, every figure the same size, evenly spaced, each in its own equal cell, all standing on the same baseline within their row. +Rows (one view per row, top to bottom): 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. +Columns (animation frames, left to right): 1 wind-up - the spear pulled back, held level at waist height with both hands, the tip pointing forward, body leaning back; 2 strike - the spear pushed forward, body leaning forward; 3 impact - the spear fully thrust forward, arms stretched out, body lunging forward, a small white spark at the spear tip; 4 recover - the spear pulled back to the ready position, body upright. +The feet stay in place in all four frames; only arms, weapon, head and upper body move. +Every frame of a row keeps exactly that row's view direction (the side view row stays in profile facing right in all four frames, the back rows never show the face), and the character holds the same wooden spear with the steel tip in the right hand in every single frame. + +Style: identical to the attached sheet - crisp retro pixel art, hard square pixels, one-pixel near-black outline, limited palette, no anti-aliasing, no blur. Flat pure magenta (#FF00FF) background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines, no motion lines. +``` + +The first four front requests (`*-front-raw.png`) were sent without the "Every frame of a row +keeps ..." sentence and without "no motion lines"; the knight attack strike frame also asked +for "a short white motion arc behind the blade" (the model drew big white swoosh rings; removed). +The back prompt is the imp's back prompt with the character's name and frame texts. + +### pix + +Per view row: the row is cut out of the raw (bands found by the empty rows between them, +falling back to cuts at the emptiest rows near even splits when weapons touch the next row), +pixelized with `tools/pixelize_sprite.py --grid 1xN --size S --feet S-1 --outline-weight 0.6 +--palette ../-S.png`, and: + +- **scale**: the step is iterated until the median *core height* of the row's figures equals + the core height of the idle view. Core = from the first pixel row whose longest opaque run is + at least 20% of the cell wide (a spear tip or a raised blade is narrower, the head is wider) + to the last row with a run of at least 3 px. So a spear above the head or a sword raised in + the wind-up does not shrink the body. +- **position**: each frame is shifted so that its core bottom is on the feet row and the median + centre of its core rows is at the idle view's centre (the tool itself centres by bounding box, + which would push the body away from an outstretched weapon). Pixels pushed out of the cell are + cut (logged as `CLIPPED n px`). +- **check**: the log prints per view the ratio of the frames' median body width to the idle + view's (`width ratio`, flagged beyond +-10%): the model sometimes redraws the character + stockier or slimmer, which no scaling can hide. +- `--view V=SPEC` takes the frames of view V from other rows or files: + `FILE:BAND/NBANDS:COUNT:PICKS[;FILE:...]` (empty FILE = the front raw; PICKS like `0,2,1,3`, + `m1` mirrors). Every source row is scaled on its own, so frames of different raws can be mixed. + +## Requests (11 images, about $1.65 at ~$0.15 per 2K image) + +| raw | result | +| --- | --- | +| `knight-walk-front-raw.png` | rows 1-2 ok; row 3 had SIX figures (4 side + 2 back three-quarter), rows 4-5 mixed. Side row used (figures 0,2,1,3: stride, pass, stride, pass) | +| `knight-walk-front-raw-2.png` (retry) | all 5 rows correct; front, front 3/4, back 3/4, back used; its side row shows the same stride four times | +| `knight-attack-front-raw.png` | front and front 3/4 rows nearly identical, side row turned to the front in frames 2-3, big white swoosh rings: dropped | +| `knight-attack-front-raw-2.png` (retry, new sentence) | rows 1-3 good (raised, over the helm, low at the left, ready) | +| `knight-attack-back-raw.png` | strike frame (column 2) showed the visor and the chest emblem in both rows: dropped | +| `knight-attack-back-raw-2.png` (retry, `--strict`) | good: all from behind, row 1 slightly turned | +| `kobold-walk-front-raw.png` | spear missing in the passing frames of the front and side rows: dropped | +| `kobold-walk-front-raw-2.png` (retry) | SIX frames per row, spear in nearly all; figures 0,1,3,4 used; back 3/4 row inconsistent | +| `kobold-walk-back-raw.png` (`--strict`, reference = the first 4 columns of raw-2, `kobold-walk-front-raw-2-4cols.png`) | good back views but the spear vanishes in the passing frames; frames 0 and 2 used | +| `kobold-attack-front-raw.png` | very good, rows 1-5 all right | +| `kobold-attack-back-raw.png` | row 1 good; row 2 frames 2-3 are back three-quarter views instead of the back view | + +## Commands used + +```bash +cd art/generated/sprites/anim; P="/tmp/sprvenv/bin/python -I"; S=../scripts/make_anim.py +$P $S pix knight walk --front knight-walk-front-raw-2.png --view "2=knight-walk-front-raw.png:2/4:6:0,2,1,3" \ + --view "3=knight-walk-front-raw-2.png:3/5:4:0,1,2,3" --view "4=knight-walk-front-raw-2.png:4/5:4:0,1,2,3" +$P $S pix knight attack --front knight-attack-front-raw-2.png --back knight-attack-back-raw-2.png +$P $S pix kobold walk --front kobold-walk-front-raw-2.png \ + --view "0=:0/5:6:0,1,3,4" --view "1=:1/5:6:0,1,3,4" --view "2=:2/5:6:0,1,3,4" \ + --view "3=kobold-walk-back-raw.png:0/2:4:0;:3/5:6:4;kobold-walk-back-raw.png:0/2:4:2;:3/5:6:4" \ + --view "4=:4/5:6:0,1,3,4" +$P $S pix kobold attack --view "4=kobold-attack-back-raw.png:1/2:4:0;kobold-attack-front-raw.png:4/5:4:1,2;kobold-attack-back-raw.png:1/2:4:3" +``` + +No extra colours were needed: the steel of both weapons is in the idle palettes. + +## Quality + +- **knight-walk**: usable. Height and feet line up with idle (core height = the full cell, as + in idle), no vertical shift. The model drew the knight about 5-15% wider/stockier than the idle + sheet (width ratio 1.01-1.18 in the log), so switching idle -> walk shows a slightly broader + knight. Leg motion is small in the front views; the side view comes from the first raw (a + separate image, 6 figures in that row) and is a real stride/pass cycle but differs a little in + shading from the other rows. +- **knight-attack**: good motion, readable in every view (sword raised over the shoulder, + over the helm, low at the left, ready). Same stockiness as the walk (width ratio up to 1.3 in + the front three-quarter row, partly the sword). The knight fills the whole cell height, so + a sword raised above the helm is cut at the top of the cell (the tool crops it; not counted in + the CLIPPED log). The low impact blade loses up to 5 px at the cell side (64). + +- **kobold-walk**: usable. Body, head and feet line up with idle in every view (core heights + equal, no vertical shift). The leg motion is subtle (the model draws small steps), the spear + stays in the hand in all frames. Back three-quarter: frames 2 and 4 are the same figure + (from the front raw-2), frames 1 and 3 come from the back request and are drawn a little + narrower, so that view jitters slightly. +- **kobold-attack**: good. Clear thrust in all views; body and feet line up with idle. The + level spear is longer than the cell, so its butt or tip is cut at the cell edge in most + frames (up to 8 px at 32, 33 px at 64 in the front impact frame). The front three-quarter + wind-up holds the spear pointing backwards. Back view: frames 1 and 4 from the back request, + frames 2-3 from row 5 of the front raw (the back request had back three-quarter figures there). + The white impact spark is mapped to the nearest palette colour (light grey) or lost. + +## Raw files + +The raws `*-raw*.png` are large and not covered by `.gitignore` (which only ignores +`art/generated/sprites/*-raw*.png`); add `art/generated/sprites/anim/*-raw*.png` before +committing. diff --git a/art/generated/sprites/anim/knight-attack-32-preview.png b/art/generated/sprites/anim/knight-attack-32-preview.png new file mode 100644 index 0000000..46a40c4 Binary files /dev/null and b/art/generated/sprites/anim/knight-attack-32-preview.png differ diff --git a/art/generated/sprites/anim/knight-attack-32.png b/art/generated/sprites/anim/knight-attack-32.png new file mode 100644 index 0000000..0942cbf Binary files /dev/null and b/art/generated/sprites/anim/knight-attack-32.png differ diff --git a/art/generated/sprites/anim/knight-attack-64-preview.png b/art/generated/sprites/anim/knight-attack-64-preview.png new file mode 100644 index 0000000..3e19c50 Binary files /dev/null and b/art/generated/sprites/anim/knight-attack-64-preview.png differ diff --git a/art/generated/sprites/anim/knight-attack-64.png b/art/generated/sprites/anim/knight-attack-64.png new file mode 100644 index 0000000..738b38e Binary files /dev/null and b/art/generated/sprites/anim/knight-attack-64.png differ diff --git a/art/generated/sprites/anim/knight-walk-32-preview.png b/art/generated/sprites/anim/knight-walk-32-preview.png new file mode 100644 index 0000000..66aecf9 Binary files /dev/null and b/art/generated/sprites/anim/knight-walk-32-preview.png differ diff --git a/art/generated/sprites/anim/knight-walk-32.png b/art/generated/sprites/anim/knight-walk-32.png new file mode 100644 index 0000000..8b9ebb4 Binary files /dev/null and b/art/generated/sprites/anim/knight-walk-32.png differ diff --git a/art/generated/sprites/anim/knight-walk-64-preview.png b/art/generated/sprites/anim/knight-walk-64-preview.png new file mode 100644 index 0000000..eeb0624 Binary files /dev/null and b/art/generated/sprites/anim/knight-walk-64-preview.png differ diff --git a/art/generated/sprites/anim/knight-walk-64.png b/art/generated/sprites/anim/knight-walk-64.png new file mode 100644 index 0000000..abcb949 Binary files /dev/null and b/art/generated/sprites/anim/knight-walk-64.png differ diff --git a/art/generated/sprites/anim/kobold-attack-32-preview.png b/art/generated/sprites/anim/kobold-attack-32-preview.png new file mode 100644 index 0000000..2444b64 Binary files /dev/null and b/art/generated/sprites/anim/kobold-attack-32-preview.png differ diff --git a/art/generated/sprites/anim/kobold-attack-32.png b/art/generated/sprites/anim/kobold-attack-32.png new file mode 100644 index 0000000..cbdb29b Binary files /dev/null and b/art/generated/sprites/anim/kobold-attack-32.png differ diff --git a/art/generated/sprites/anim/kobold-attack-64-preview.png b/art/generated/sprites/anim/kobold-attack-64-preview.png new file mode 100644 index 0000000..a9b795e Binary files /dev/null and b/art/generated/sprites/anim/kobold-attack-64-preview.png differ diff --git a/art/generated/sprites/anim/kobold-attack-64.png b/art/generated/sprites/anim/kobold-attack-64.png new file mode 100644 index 0000000..e668a40 Binary files /dev/null and b/art/generated/sprites/anim/kobold-attack-64.png differ diff --git a/art/generated/sprites/anim/kobold-walk-32-preview.png b/art/generated/sprites/anim/kobold-walk-32-preview.png new file mode 100644 index 0000000..bdb1f66 Binary files /dev/null and b/art/generated/sprites/anim/kobold-walk-32-preview.png differ diff --git a/art/generated/sprites/anim/kobold-walk-32.png b/art/generated/sprites/anim/kobold-walk-32.png new file mode 100644 index 0000000..75a37b9 Binary files /dev/null and b/art/generated/sprites/anim/kobold-walk-32.png differ diff --git a/art/generated/sprites/anim/kobold-walk-64-preview.png b/art/generated/sprites/anim/kobold-walk-64-preview.png new file mode 100644 index 0000000..521c4a3 Binary files /dev/null and b/art/generated/sprites/anim/kobold-walk-64-preview.png differ diff --git a/art/generated/sprites/anim/kobold-walk-64.png b/art/generated/sprites/anim/kobold-walk-64.png new file mode 100644 index 0000000..d93ee86 Binary files /dev/null and b/art/generated/sprites/anim/kobold-walk-64.png differ diff --git a/art/generated/sprites/scripts/make_anim.py b/art/generated/sprites/scripts/make_anim.py new file mode 100644 index 0000000..e9e3dae --- /dev/null +++ b/art/generated/sprites/scripts/make_anim.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""Animation sheets (4 frames x 5 views) for one creature, in the imp's hack-sheet layout. + + make_anim.py gen [front|back|both] [--suffix S] fal requests (nano-banana-pro/edit) + make_anim.py pix [--front F] [--back B] pixelize, align, stack, preview + + is walk or attack. Files (in art/generated/sprites/anim/): + --front-raw.png request 1: the 5x4 sheet, rows 1-3 (front, front 3/4, side) used + --back-raw.png request 2: 2x4 sheet, back three-quarter and back + --32.png (128x160), --64.png (256x320), ---preview.png + +Run with the Pillow venv: /tmp/sprvenv/bin/python -I make_anim.py ... +The key is read from ~/.config/dungeoncraft/fal-key and never printed. + +Pixel grid: every view row is pixelized with tools/pixelize_sprite.py (--grid 1x4, the idle +sheet's palette). The step (source pixels per output pixel) is chosen per row so that the +figures' "core" height (the body without thin weapon parts sticking out above or below, see +core()) is the median core height of the matching idle view; each frame is then shifted so that +its core bottom sits on the feet row and its core centre on the idle view's core centre. +""" +import argparse, base64, io, json, os, statistics, subprocess, sys, urllib.request + +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +SPR = os.path.normpath(os.path.join(HERE, "..")) +ANIM = os.path.join(SPR, "anim") +ROOT = os.path.normpath(os.path.join(SPR, "..", "..", "..")) +TOOL = os.path.join(ROOT, "tools", "pixelize_sprite.py") +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(ROOT, "tools")) +from creatures import BY_ID, GREEN # noqa: E402 +import pixelize_sprite as ps # noqa: E402 + +VIEWS = ["front", "front three-quarter", "side", "back three-quarter", "back"] +SIZES = ((32, 31), (64, 63)) # cell size, feet row + +# Per creature: the weapon (as it appears in the idle sheet) and the attack frames. +# Front views: what the frames show from the front; back: the same, seen from behind. +WEAPON = { + "knight": "the same short straight steel sword in the right hand", + "kobold": "the same wooden spear with the steel tip in the right hand", +} +ATTACK = { + "knight": dict( + what="a sword swing: the knight swings the sword with the right hand from high over the right shoulder diagonally down across the body at an enemy in front of it", + front=["wind-up - the sword raised high over the right shoulder, the blade pointing up and back, body turned slightly back", + "strike - the sword swinging down diagonally in front of the helm, body turning forward", + "impact - the sword fully swung through, the blade low in front of the body at the left hip, body leaning forward", + "recover - the sword pulled back up to the ready position at chest height, body upright"], + back=["wind-up - the sword raised high over the right shoulder, the blade showing above the helm", + "strike - the sword coming down, partly hidden by the helm and shoulders", + "impact - the sword in front of the knight, mostly hidden by the body, only the blade tip shows low at the left side", + "recover - the sword pulled back up, its blade visible beside the right shoulder"]), + "kobold": dict( + what="a spear thrust: the kobold holds the spear level with both hands and stabs it forward at an enemy in front of it", + front=["wind-up - the spear pulled back, held level at waist height with both hands, the tip pointing forward, body leaning back", + "strike - the spear pushed forward, body leaning forward", + "impact - the spear fully thrust forward, arms stretched out, body lunging forward, a small white spark at the spear tip", + "recover - the spear pulled back to the ready position, body upright"], + back=["wind-up - the spear pulled back and held level at waist height, its butt end sticking out behind the kobold", + "strike - the spear pushed forward away from the viewer, mostly hidden by the body", + "impact - the spear fully thrust forward, arms stretched out, the spear hidden by the body except a short piece of shaft, a small white spark at the tip beyond the head", + "recover - the spear pulled back to the ready position, the shaft visible beside the body"]), +} +WALK_FRONT = ["contact - the right leg stepping forward with the heel down, the left leg behind, arms swinging opposite to the legs", + "passing - the legs passing each other under the body, the body slightly higher", + "contact - the left leg stepping forward with the heel down, the right leg behind, arms swinging the other way", + "passing - the legs passing each other under the body, the body slightly higher"] +WALK_BACK = WALK_FRONT + + +def bg_of(cid): + return ("green (#00FF00)", (0, 255, 0)) if cid in GREEN else ("magenta (#FF00FF)", (255, 0, 255)) + + +def anim_text(cid, anim, part): + c = BY_ID[cid] + weapon = WEAPON.get(cid, "") + if anim == "walk": + what = f"a looping walk cycle: the {c['name'].lower()} walks forward in the direction it faces, at a steady pace" + ( + f", carrying {weapon} as in the idle pose" if weapon else "") + frames = WALK_FRONT if part == "front" else WALK_BACK + feet = "The figure walks in place (it does not move across the cell); the feet stand on the baseline of the row." + else: + a = ATTACK[cid] + what = a["what"] + frames = a[part] + feet = "The feet stay in place in all four frames; only arms, weapon, head and upper body move." + cols = "; ".join(f"{i + 1} {f}" for i, f in enumerate(frames)) + return what, cols, feet + + +def prompt_front(cid, anim): + c = BY_ID[cid] + bgname, _ = bg_of(cid) + what, cols, feet = anim_text(cid, anim, "front") + hold = f"the character holds {WEAPON[cid]}" if cid in WEAPON else "the character keeps all its equipment" + return f"""The attached image is a pixel art sprite sheet of the "{c['name']}" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). {c['desc']} + +Create a new pixel art sprite sheet of exactly the same character - same proportions, colours, outline, pixel size, clothes and equipment - showing {what}. + +Layout: a grid of 5 rows and 4 columns, every figure the same size, evenly spaced, each in its own equal cell, all standing on the same baseline within their row. +Rows (one view per row, top to bottom): 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. +Columns (animation frames, left to right): {cols}. +{feet} +Every frame of a row keeps exactly that row's view direction (the side view row stays in profile facing right in all four frames, the back rows never show the face), and {hold} in every single frame. + +Style: identical to the attached sheet - crisp retro pixel art, hard square pixels, one-pixel near-black outline, limited palette, no anti-aliasing, no blur. Flat pure {bgname} background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines, no motion lines.""" + + +def prompt_back(cid, anim, retry=False): + c = BY_ID[cid] + bgname, _ = bg_of(cid) + what, cols, feet = anim_text(cid, anim, "back") + note = ("Every one of the eight figures, including the strike frames, is seen from behind: no visor, no face, no eyes and no chest emblem anywhere. " + "The back three-quarter row shows the body turned clearly to the right (a little of its right side visible), the back row shows it straight from behind.\n") if retry else "" + return f"""The first attached image is a pixel art sprite sheet of the "{c['name']}" in five idle views (front, front three-quarter, side, back three-quarter, back). The second attached image shows the same character's animation ({what}) in four frames (columns) for the front, front three-quarter and side views. + +Create the same four-frame animation for the two views seen FROM BEHIND, where the character faces away from the viewer and we see the back of its head, its back and its clothes from behind; the face and eyes are never visible: +{note}Row 1: back three-quarter view, the character turned away from the viewer towards the upper right. +Row 2: back view, the character seen straight from behind. +Columns (frames, left to right): {cols}. +{feet} + +Layout: a grid of exactly 2 rows and 4 columns, every figure the same size and the same character size as in the attached sheets, evenly spaced, each in its own equal cell, all standing on the same baseline. +Style: identical to the attached sheets - same character, proportions, colours, outline and pixel size, crisp retro pixel art, hard square pixels, no anti-aliasing, no blur. Flat pure {bgname} background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines.""" + + +def idle_crop(cid): + """The --crop of the idle sheet (scripts/extra.txt), as fractions, or None.""" + p = os.path.join(HERE, "extra.txt") + if os.path.exists(p): + for line in open(p): + parts = line.split() + if parts and parts[0] == cid and "--crop" in parts: + return [float(v) for v in parts[parts.index("--crop") + 1].split(",")] + return None + + +def clean_ref(img, key): + """The image with every background pixel (and frame line) painted in the flat key colour.""" + img = img.convert("RGB") + mask = ps.background_mask(img) + w, h = img.size + out = img.copy() + px = out.load() + for y in range(h): + row = mask[y] + for x in range(w): + if row[x]: + px[x, y] = key + return out + + +def uri(img): + b = io.BytesIO() + img.save(b, "PNG") + return "data:image/png;base64," + base64.b64encode(b.getvalue()).decode() + + +def fal(prompt, images, aspect): + key = open(os.path.expanduser("~/.config/dungeoncraft/fal-key")).read().strip() + body = {"prompt": prompt, "image_urls": [uri(i) for i in images], "aspect_ratio": aspect, + "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"}) + with urllib.request.urlopen(req, timeout=900) as r: + res = json.load(r) + with urllib.request.urlopen(res["images"][0]["url"], timeout=300) as r: + return Image.open(io.BytesIO(r.read())).convert("RGB") + + +def idle_ref(cid): + raw = Image.open(os.path.join(SPR, f"{cid}-raw.png")).convert("RGB") + f = idle_crop(cid) + if f: + w, h = raw.size + raw = raw.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h))) + raw = raw.resize((raw.width // 2, raw.height // 2), Image.LANCZOS) + return clean_ref(raw, bg_of(cid)[1]) + + +def row_bands(img, n): + """The horizontal bands (y0, y1) of figure rows: split at empty rows (bands lower than 6% of + the image are dropped); if that does not give n bands, n bands split at the emptiest rows + near the even cuts.""" + mask = [[not v for v in r] for r in ps.background_mask(img)] + occ = [sum(r) for r in mask] + h = len(occ) + bands, y0 = [], None + for y, v in enumerate(occ + [0]): + if v > 2 and y0 is None: + y0 = y + elif v <= 2 and y0 is not None: + if y - y0 > 0.06 * h: + bands.append((y0, y)) + y0 = None + if len(bands) == n: + # widen to the midpoints of the gaps + cuts = [0] + [(bands[i][1] + bands[i + 1][0]) // 2 for i in range(n - 1)] + [h] + return list(zip(cuts[:-1], cuts[1:])) + print(f" note: {len(bands)} bands found by gaps, wanted {n}; cutting at the emptiest rows") + cuts = [0] + for k in range(1, n): + c = k * h // n + lo, hi = c - h // (2 * n), c + h // (2 * n) + cuts.append(min(range(lo, hi), key=lambda y: (occ[y], abs(y - c)))) + cuts.append(h) + return list(zip(cuts[:-1], cuts[1:])) + + +def cmd_gen(a): + os.makedirs(ANIM, exist_ok=True) + ref = idle_ref(a.id) + front = os.path.join(ANIM, f"{a.id}-{a.anim}-front-raw{a.suffix}.png") + if a.part in ("front", "both"): + img = fal(prompt_front(a.id, a.anim), [ref], "4:5") + img.save(front) + print("wrote", front) + if a.part in ("back", "both"): + src = a.front_src or front + fr = Image.open(src).convert("RGB") + bands = row_bands(fr, 5) + top = fr.crop((0, 0, fr.width, bands[2][1])) + img = fal(prompt_back(a.id, a.anim, a.strict), [ref, top], "16:9") + p = os.path.join(ANIM, f"{a.id}-{a.anim}-back-raw{a.suffix}.png") + img.save(p) + print("wrote", p) + + +# ---- pixel grid ------------------------------------------------------------------------- + +def opaque(cell): + w, h = cell.size + px = cell.load() + return [[px[x, y][3] > 128 for x in range(w)] for y in range(h)] + + +def longest_run(row): + best, start, cur = (0, 0), 0, 0 + for x, v in enumerate(row + [False]): + if v: + if cur == 0: + start = x + cur += 1 + else: + if cur > best[0]: + best = (cur, start) + cur = 0 + return best + + +def core(cell, size): + """(top, bottom, centre x) of the body: top = the first row whose longest opaque run is at + least 20% of the cell wide (a spear tip or blade above the head is narrower), bottom = the + last row with a run of at least 3 px (feet; a thin shaft below them is not), centre = the + median centre of the longest runs of the rows in between. None if the cell is empty.""" + m = opaque(cell) + runs = [longest_run(r) for r in m] + wide = max(3, round(0.2 * size)) + tops = [y for y, (n, _) in enumerate(runs) if n >= wide] + bots = [y for y, (n, _) in enumerate(runs) if n >= 3] + if not tops or not bots: + return None + top, bot = tops[0], bots[-1] + cx = statistics.median(s + n / 2 for n, s in runs[top:bot + 1] if n >= wide) + return top, bot, cx + + +def body_width(cell, size): + """Median longest opaque run over the core rows (head and body width, for checking that the + animation keeps the idle proportions).""" + c = core(cell, size) + runs = [longest_run(r)[0] for r in opaque(cell)[c[0]:c[1] + 1]] + return statistics.median(runs) + + +def cells(sheet, size, row): + return [sheet.crop((c * size, row * size, (c + 1) * size, (row + 1) * size)) for c in range(sheet.width // size)] + + +def run_tool(raw_crop, n, size, feet, step, palette, add): + tmp = f"/tmp/make_anim_{os.getpid()}" + raw_crop.save(tmp + "-in.png") + cmd = [sys.executable, "-I", TOOL, tmp + "-in.png", tmp, "--grid", f"1x{n}", "--size", str(size), "--feet", str(feet), + "--outline-weight", "0.6", "--palette", palette] + if step: + cmd += ["--step", f"{step:.4f}"] + if add: + cmd += ["--add-colors", add] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode: + raise SystemExit(r.stderr) + out = Image.open(tmp + f"-{size}.png").convert("RGBA") + out.load() + for f in (tmp + "-in.png", tmp + f"-{size}.png", tmp + f"-{size}-preview.png"): + if os.path.exists(f): + os.remove(f) + last = r.stdout.strip().splitlines()[-1] + used = float(last.split("step ")[1].split(",")[0]) + return out, used + + +def shift(cell, dx, dy): + out = Image.new("RGBA", cell.size, (0, 0, 0, 0)) + out.alpha_composite(cell, (max(dx, 0), max(dy, 0)), (max(-dx, 0), max(-dy, 0))) + lost = sum(1 for p in cell.get_flattened_data() if p[3] > 128) - sum(1 for p in out.get_flattened_data() if p[3] > 128) + return out, lost + + +def pix_source(raw_crop, n, target, size, feet, palette, add): + """All n figures of one raw row, at the step that makes their median core height = target.""" + step = None + for _ in range(4): # the core height is linear in 1/step; converge in a few passes + sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add) + hs = [c[1] - c[0] for c in (core(x, size) for x in cells(sheet, size, 0)) if c] + new = used * statistics.median(hs) / target + if step and abs(new - step) / step < 0.01: + break + step = new + sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add) + return cells(sheet, size, 0), used + + +def parse_view(spec, default_file): + """'FILE:BAND/NBANDS:COUNT:PICKS[;...]' -> [(file, band, nbands, count, [picks])]; FILE may + be empty (= default_file), PICKS like 0,2,1,3 (m = mirrored, e.g. m1).""" + out = [] + for seg in spec.split(";"): + f, bn, count, picks = seg.split(":") + band, nb = (int(v) for v in bn.split("/")) + out.append((f or default_file, band, nb, int(count), picks.split(","))) + return out + + +def cmd_pix(a): + pre = os.path.join(ANIM, f"{a.id}-{a.anim}") + front = a.front or pre + "-front-raw.png" + back = a.back or pre + "-back-raw.png" + specs = [f"{front}:{v}/5:4:0,1,2,3" for v in range(3)] + [f"{back}:{v}/2:4:0,1,2,3" for v in range(2)] + for v in a.view or []: + k, spec = v.split("=", 1) + specs[int(k)] = spec + views = [parse_view(sp, front) for sp in specs] + images, bands = {}, {} + for segs in views: + for f, band, nb, count, picks in segs: + p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f) + if p not in images: + images[p] = Image.open(p).convert("RGB") + if (p, nb) not in bands: + bands[p, nb] = row_bands(images[p], nb) + for size, feet in SIZES: + idle_path = os.path.join(SPR, f"{a.id}-{size}.png") + idle = Image.open(idle_path).convert("RGBA") + sheet = Image.new("RGBA", (4 * size, 5 * size), (0, 0, 0, 0)) + log = [f"{a.id}-{a.anim}-{size}"] + for v, segs in enumerate(views): + icell = idle.crop((v * size, 0, (v + 1) * size, size)) + ic, iw = core(icell, size), body_width(icell, size) + frames, notes = [], [] + for f, band, nb, count, picks in segs: + p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f) + y0, y1 = bands[p, nb][band] + im = images[p] + figs, used = pix_source(im.crop((0, y0, im.width, y1)), count, ic[1] - ic[0], size, feet, idle_path, a.add_colors) + notes.append(f"{os.path.basename(p)} row {band}/{nb} step {used:.2f}") + for t in picks: + c = figs[int(t.lstrip("m"))] + frames.append(c.transpose(Image.FLIP_LEFT_RIGHT) if t.startswith("m") else c) + if len(frames) != 4: + raise SystemExit(f"view {v}: {len(frames)} frames, want 4") + fn = [] + wr = statistics.median(body_width(f, size) for f in frames) / iw + for col, cell in enumerate(frames): + c = core(cell, size) + dx, dy = round(ic[2] - c[2]), feet - c[1] + moved, lost = shift(cell, dx, dy) + sheet.alpha_composite(moved, (col * size, v * size)) + fn.append(f"h{c[1] - c[0]} dx{dx:+d} dy{dy:+d}" + (f" CLIPPED {lost}px" if lost else "")) + log.append(f" {VIEWS[v]} (idle core h {ic[1] - ic[0]}, width ratio {wr:.2f}" + + (" WIDER/NARROWER THAN IDLE" if abs(wr - 1) > 0.1 else "") + "): " + "; ".join(notes) + " | " + ", ".join(fn)) + out = pre + f"-{size}.png" + sheet.save(out) + preview(sheet, idle, size, pre + f"-{size}-preview.png") + print("\n".join(log)) + print("wrote", out) + + +def preview(sheet, idle, size, path): + sc = 8 if size == 32 else 4 + pad = size // 4 + w = sheet.width + pad + size + im = Image.new("RGBA", (w, sheet.height), (96, 92, 104, 255)) + im.alpha_composite(sheet, (0, 0)) + for v in range(5): + im.alpha_composite(idle.crop((v * size, 0, (v + 1) * size, size)), (sheet.width + pad, v * size)) + # feet row guide + big = im.resize((im.width * sc, im.height * sc), Image.NEAREST) + px = big.load() + for v in range(5): + y = (v * size + size) * sc - 1 + for x in range(0, big.width, 3): + if px[x, y][3] and px[x, y][:3] == (96, 92, 104): + px[x, y] = (150, 146, 160, 255) + big.save(path) + + +def main(): + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + g = sub.add_parser("gen") + g.add_argument("id") + g.add_argument("anim", choices=["walk", "attack"]) + g.add_argument("part", nargs="?", default="both", choices=["front", "back", "both"]) + g.add_argument("--suffix", default="") + g.add_argument("--front-src", default=None, help="front raw to show in the back request") + g.add_argument("--strict", action="store_true", help="back request: stress that every frame is seen from behind (retry)") + g.add_argument("--print", action="store_true", help="only print the prompts") + p = sub.add_parser("pix") + p.add_argument("id") + p.add_argument("anim", choices=["walk", "attack"]) + p.add_argument("--front", default=None) + p.add_argument("--back", default=None) + p.add_argument("--view", action="append", help="V=FILE:BAND/NBANDS:COUNT:PICKS[;...] frames of view V " + "(0 front .. 4 back) from other rows/files; default views 0-2 = rows 0-2 of 5 of the front raw, " + "3-4 = rows 0-1 of 2 of the back raw, 4 figures each") + p.add_argument("--add-colors", default="", help="extra palette colours R,G,B;R,G,B") + a = ap.parse_args() + if a.cmd == "gen": + if a.print: + print(prompt_front(a.id, a.anim), "\n\n----\n", prompt_back(a.id, a.anim)) + else: + cmd_gen(a) + else: + cmd_pix(a) + + +if __name__ == "__main__": + main() diff --git a/assets/sprites/knight-attack-32.png b/assets/sprites/knight-attack-32.png new file mode 100644 index 0000000..0942cbf Binary files /dev/null and b/assets/sprites/knight-attack-32.png differ diff --git a/assets/sprites/knight-attack-64.png b/assets/sprites/knight-attack-64.png new file mode 100644 index 0000000..738b38e Binary files /dev/null and b/assets/sprites/knight-attack-64.png differ diff --git a/assets/sprites/knight-walk-32.png b/assets/sprites/knight-walk-32.png new file mode 100644 index 0000000..8b9ebb4 Binary files /dev/null and b/assets/sprites/knight-walk-32.png differ diff --git a/assets/sprites/knight-walk-64.png b/assets/sprites/knight-walk-64.png new file mode 100644 index 0000000..abcb949 Binary files /dev/null and b/assets/sprites/knight-walk-64.png differ diff --git a/assets/sprites/kobold-attack-32.png b/assets/sprites/kobold-attack-32.png new file mode 100644 index 0000000..cbdb29b Binary files /dev/null and b/assets/sprites/kobold-attack-32.png differ diff --git a/assets/sprites/kobold-attack-64.png b/assets/sprites/kobold-attack-64.png new file mode 100644 index 0000000..e668a40 Binary files /dev/null and b/assets/sprites/kobold-attack-64.png differ diff --git a/assets/sprites/kobold-walk-32.png b/assets/sprites/kobold-walk-32.png new file mode 100644 index 0000000..75a37b9 Binary files /dev/null and b/assets/sprites/kobold-walk-32.png differ diff --git a/assets/sprites/kobold-walk-64.png b/assets/sprites/kobold-walk-64.png new file mode 100644 index 0000000..d93ee86 Binary files /dev/null and b/assets/sprites/kobold-walk-64.png differ diff --git a/src/gfx.rs b/src/gfx.rs index 7db9ae2..30ff1e7 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1780,6 +1780,7 @@ pub enum HdAnim { Hack, Walk, Attack, + Die, } pub const ANIM_FRAMES: u32 = 4; struct HdSprite { @@ -1797,6 +1798,18 @@ macro_rules! hd_sprite { anims: &[], } }; + // with walking and attacking (assets/sprites/-walk-64.png etc.) + ($kind:expr, $name:literal, animated) => { + HdSprite { + kind: $kind, + idle: include_bytes!(concat!("../assets/sprites/", $name, "-64.png")), + idle_32: include_bytes!(concat!("../assets/sprites/", $name, "-32.png")), + anims: &[ + (HdAnim::Walk, include_bytes!(concat!("../assets/sprites/", $name, "-walk-64.png")), include_bytes!(concat!("../assets/sprites/", $name, "-walk-32.png"))), + (HdAnim::Attack, include_bytes!(concat!("../assets/sprites/", $name, "-attack-64.png")), include_bytes!(concat!("../assets/sprites/", $name, "-attack-32.png"))), + ], + } + }; } const HD_SPRITES: [HdSprite; 31] = [ HdSprite { @@ -1805,11 +1818,11 @@ const HD_SPRITES: [HdSprite; 31] = [ idle_32: include_bytes!("../assets/sprites/imp-32.png"), anims: &[(HdAnim::Hack, include_bytes!("../assets/sprites/imp-hack-64.png"), include_bytes!("../assets/sprites/imp-hack-32.png"))], }, - hd_sprite!(EK::Kobold, "kobold"), + hd_sprite!(EK::Kobold, "kobold", animated), hd_sprite!(EK::Spider, "spider"), hd_sprite!(EK::Mage, "mage"), hd_sprite!(EK::Ogre, "ogre"), - hd_sprite!(EK::Knight, "knight"), + hd_sprite!(EK::Knight, "knight", animated), hd_sprite!(EK::Archer, "archer"), hd_sprite!(EK::Priest, "priest"), hd_sprite!(EK::Dwarf, "dwarf"), diff --git a/src/render3d.rs b/src/render3d.rs index 928955a..06fa061 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1264,8 +1264,25 @@ pub struct R3d { lava: Vec, // lava light per tile (see lava_field) chunks_hd: bool, // the chunks were built with the high-res atlas chunks_holiday: u8, // ... and with this holiday's decorations + dying: Vec, // units shown dying (see update_dying) + seen: Vec, // the units of the last frame (their last looks) + seen_time: f32, // the game time of the last frame } +// a unit that has just died, shown falling for DEATH_T seconds of game time (only the renderer +// knows: the game removes the dead at once) +#[derive(Clone, Copy)] +struct Dying { + id: u32, + kind: EK, + x: f32, + z: f32, + dir: f32, + level: u32, + t0: f32, +} +const DEATH_T: f32 = 1.4; + // The 3D resolution setting refers to a window 1080 device pixels high: smaller windows use a // proportionally smaller divisor, so the 3D pixels keep their size relative to the window. // 1 (full resolution) stays 1. @@ -1314,7 +1331,7 @@ fn chunk_sig(map: &Map, cx: i32, cz: i32) -> u64 { impl R3d { pub fn new() -> R3d { - R3d { lit: lit_material(), glow: glow_material(), opaque: opaque_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS, pixel_rows: crate::settings::PIXEL_ART_ROWS as u32, target: None, lava: Vec::new(), chunks_hd: false, chunks_holiday: 0 } + R3d { lit: lit_material(), glow: glow_material(), opaque: opaque_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS, pixel_rows: crate::settings::PIXEL_ART_ROWS as u32, target: None, lava: Vec::new(), chunks_hd: false, chunks_holiday: 0, dying: Vec::new(), seen: Vec::new(), seen_time: 0.0 } } // Changing the wall detail rebuilds all chunks @@ -1662,6 +1679,7 @@ impl R3d { // sprites as billboards // the world's pixel rows (detailed sprites only where they get enough pixels) let world_rows = pix.map_or(screen_height() * screen_dpi_scale(), |(_, th, _)| th as f32); + self.update_dying(g); for mesh in self.billboards(g, gfx, cam, t, world_rows).iter() { draw_mesh(mesh); } @@ -1739,6 +1757,29 @@ impl R3d { self.draw_2d(g, gfx, cam, &m, t); } + // units that vanished since the last frame died (or left): they are shown falling for a + // moment; a new game, a loaded one or a jump in time starts afresh + fn update_dying(&mut self, g: &Game) { + let now: Vec = g + .ents + .iter() + .filter(|e| e.alive) + .map(|e| Dying { id: e.id, kind: e.kind, x: e.disp_x, z: e.disp_y, dir: e.dir, level: e.level, t0: g.time }) + .collect(); + if g.time < self.seen_time || g.time > self.seen_time + 2.0 { + self.dying.clear(); + } else { + let gone: Vec = self.seen.iter().filter(|d| !now.iter().any(|e| e.id == d.id)).copied().collect(); + // many at once: not deaths but a change of the map + if gone.len() <= 24 { + self.dying.extend(gone.into_iter().map(|d| Dying { t0: g.time, ..d })); + } + } + self.dying.retain(|d| g.time - d.t0 < DEATH_T); + self.seen = now; + self.seen_time = g.time; + } + // the units as billboards: the 10x10 sprites, the detailed ones (two levels) and their gear fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32, world_rows: f32) -> Vec { let (_, r, _) = cam.basis(); @@ -2014,6 +2055,52 @@ impl R3d { [toward; 4], ); } + // the dying: their death animation (detailed sprites that have one), else they topple + // over; then they sink into the floor and fade + for d in &self.dying { + let el = g.time - d.t0; + let w = sprite_size(d.kind); + let dv = vec2(d.dir.cos(), d.dir.sin()); + let ang = dv.dot(rg).atan2(dv.dot(tg)); + let view = ((ang / std::f32::consts::FRAC_PI_4).round().abs() as u32).min(4); + let mirror = ang < 0.0; + let fwd = if mirror { -1.0 } else { 1.0 }; + let texel = w * 1.2 * px_per_tile / crate::gfx::HD_CELL as f32; + let fine = texel >= 0.85; + let die = Gfx::hd_has_anim(d.kind, crate::gfx::HdAnim::Die); + let frame = ((el / 0.22) as u32).min(crate::gfx::ANIM_FRAMES - 1); + let pic = if hd && texel * 2.0 >= 0.85 { Gfx::hd_picture(d.kind, view, die.then_some((crate::gfx::HdAnim::Die, frame)), false) } else { None }; + let rot = if die && pic.is_some() { 0.0 } else { (el / 0.35).min(1.0) * std::f32::consts::FRAC_PI_2 * 0.95 * fwd }; + let sink = ((el - DEATH_T * 0.55) / (DEATH_T * 0.45)).clamp(0.0, 1.0); + let src = match pic { + Some(p) => Gfx::hd_uv(p, fine), + None => { + let r = Gfx::sprite_cell_view(d.kind, false, d.level, view); + Rect::new(r.x / sw, r.y / sh, r.w / sw, r.h / sh) + } + }; + let (mut u0, v0, mut u1, v1) = (src.x, src.y, src.x + src.w, src.y + src.h); + if mirror { + std::mem::swap(&mut u0, &mut u1); + } + // fading: the see-through look of invisible units (every other pixel) at the end + let a = amb(d.x, d.z) * (1.0 - 0.4 * sink); + let c = col(Vec3::splat(a), if sink > 0.5 { 0.35 } else { 1.0 }); + let base = vec3(d.x, -sink * 0.25 * w, d.z) + toward * 0.45; + let (sr, cr) = rot.sin_cos(); + let corner = |dx: f32, dy: f32| base + r * (dx * cr - dy * sr) + Vec3::Y * (dx * sr + dy * cr); + let mb = match pic { + Some(_) if fine => &mut mb_hd, + Some(_) => &mut mb_hd32, + None => &mut mb, + }; + mb.quad( + [corner(-w * 0.6, w * 1.1), corner(w * 0.6, w * 1.1), corner(w * 0.6, -w * 0.1), corner(-w * 0.6, -w * 0.1)], + [vec2(u0, v0), vec2(u1, v0), vec2(u1, v1), vec2(u0, v1)], + [c; 4], + [toward; 4], + ); + } let mesh = |mb: MB, tex: &Texture2D| if mb.v.is_empty() { None } else { Some(Mesh { vertices: mb.v, indices: mb.i, texture: Some(tex.clone()) }) }; [mesh(mb, &gfx.sprites), mesh(mb_hd, &gfx.hd_sprites), mesh(mb_hd32, &gfx.hd_sprites32), mesh(mb_gear, &gfx.gear)].into_iter().flatten().collect() }