diff --git a/package.json b/package.json index a0217bf..13caec3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "simulate": "node tools/simulate.mjs", "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", - "readme": "bun tools/update-readme.mjs" + "readme": "bun tools/update-readme.mjs", + "tokens": "python3 tools/make-tokens.py" }, "keywords": [], "author": "Tim Evans (slaguru666)", diff --git a/packs/lastadmission/000003.log b/packs/lastadmission/000003.log index 8198bc0..d904d3c 100644 Binary files a/packs/lastadmission/000003.log and b/packs/lastadmission/000003.log differ diff --git a/packs/npcs/000003.log b/packs/npcs/000003.log deleted file mode 100644 index 748b78a..0000000 Binary files a/packs/npcs/000003.log and /dev/null differ diff --git a/packs/npcs/000005.ldb b/packs/npcs/000005.ldb new file mode 100644 index 0000000..6e14fbe Binary files /dev/null and b/packs/npcs/000005.ldb differ diff --git a/packs/npcs/000006.log b/packs/npcs/000006.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/npcs/CURRENT b/packs/npcs/CURRENT index 1a84852..cacca75 100644 --- a/packs/npcs/CURRENT +++ b/packs/npcs/CURRENT @@ -1 +1 @@ -MANIFEST-000002 +MANIFEST-000004 diff --git a/packs/npcs/MANIFEST-000002 b/packs/npcs/MANIFEST-000002 deleted file mode 100644 index bbbc585..0000000 Binary files a/packs/npcs/MANIFEST-000002 and /dev/null differ diff --git a/packs/npcs/MANIFEST-000004 b/packs/npcs/MANIFEST-000004 new file mode 100644 index 0000000..46534f1 Binary files /dev/null and b/packs/npcs/MANIFEST-000004 differ diff --git a/packs/standingorder/000003.log b/packs/standingorder/000003.log index 933f02c..48c285a 100644 Binary files a/packs/standingorder/000003.log and b/packs/standingorder/000003.log differ diff --git a/packs/weapons/000003.log b/packs/weapons/000003.log index e20483e..98c5baa 100644 Binary files a/packs/weapons/000003.log and b/packs/weapons/000003.log differ diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index ea3ed36..4efe035 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -17,6 +17,10 @@ import { SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, PREGENS, NPCS, DEVICES, TALENTS, SITE_TABLE, HAZARD_TABLE, hitPoints, majorWound, damageModifier, styleFor } from "./content.mjs"; +import { BESTIARY_FOLKLORE } from "./bestiary-folklore.mjs"; +import { BESTIARY_WATER } from "./bestiary-water.mjs"; +import { BESTIARY_ANOMALOUS } from "./bestiary-anomalous.mjs"; +import { BESTIARY_BEASTS } from "./bestiary-beasts.mjs"; import { SHIELD } from "../rules.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -340,6 +344,27 @@ function buildActor(spec, type) { } if (found) face = `systems/ringbrp/${found}`; + /* The token is not the portrait. + * + * prototypeToken.texture pointed at `face`, and `face` is a 2:3 field-guide plate — + * full figure, plain ground. Foundry draws a token as a square on the grid, so it was + * squeezing a tall plate into it and showing a torso with the head cut off. Prefer a + * real token when tools/make-tokens.py has derived one; fall back to the portrait so + * a creature without one still gets a picture rather than the generic silhouette. */ + const tokenRel = `art/tokens/${spec.portrait ?? spec.key}.png`; + const tokenSrc = existsSync(path.join(ROOT, tokenRel)) ? `systems/ringbrp/${tokenRel}` : face; + + /* Token footprint, in grid squares, from SIZ. + * + * Every token in this system was width 1, height 1 — a hearth hob, a carthorse and a + * thirty-two SIZ dun cow all occupying one square, which reads wrong on a battle map + * and quietly misprices every question about reach, cover and who can be flanked. + * SIZ across the 184 npcs runs 3 to 32 with a median of 13, so 20 and 30 are the two + * places to cut: above 20 is bigger than any person in the book, and 30+ is only ever + * the largest beasts. */ + const tokenSquares = (Number(spec.ch?.siz) || 0) >= 30 ? 3 + : (Number(spec.ch?.siz) || 0) >= 21 ? 2 : 1; + const actorDoc = { _id: actorId, name: spec.name, type, img: face, @@ -348,7 +373,7 @@ function buildActor(spec, type) { effects: [], prototypeToken: { name: spec.name, displayName: 20, actorLink: type === "character", - width: 1, height: 1, texture: { src: face }, + width: tokenSquares, height: tokenSquares, texture: { src: tokenSrc }, sight: { enabled: true }, disposition: type === "character" ? 1 : -1, bar1: { attribute: "hp" }, bar2: { attribute: null }, flags: {} @@ -649,7 +674,20 @@ await writePack("armour", ARMOURS.map(a => { // npcs { const entries = []; - for (const n of NPCS) { + /* NPCS is no longer the whole bestiary. + * + * The 100 creatures added in 32eaee1 live in tools/bestiary-*.mjs and were wired into + * collectSpecs(), which is what check-creatures, make-portraits and mj-queue read. This + * builder reads NPCS directly, so all 100 validated, were drawn, had prompts written — + * and existed in no compendium. They were not in the game. + * + * That makes four consumers that bypass all-specs.mjs (this one, bestiary.mjs, + * check-lethality.mjs, and previously the art tools), against a module whose own header + * calls one-definition-only "this repository's one standing law". Listing the arrays + * here is the small fix; routing every consumer through collectSpecs is the real one, + * and it is written up rather than done under a content drop. */ + for (const n of [...NPCS, ...BESTIARY_FOLKLORE, ...BESTIARY_WATER, + ...BESTIARY_ANOMALOUS, ...BESTIARY_BEASTS]) { const { actorDoc, itemDocs } = buildActor(n, "npc"); entries.push([`!actors!${actorDoc._id}`, actorDoc]); for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]); diff --git a/tools/make-tokens.py b/tools/make-tokens.py new file mode 100644 index 0000000..6ebc827 --- /dev/null +++ b/tools/make-tokens.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Derive Foundry tokens from creature illustrations. + +A token is not a portrait. The illustrations are 2:3 field-guide plates — full figure, +plain ground — and build-packs.mjs was pointing prototypeToken.texture at the portrait +file, so Foundry was cropping a tall plate into a round token and showing a torso. + +So the token is DERIVED from the illustration rather than generated separately. Two +reasons. It costs nothing, where a second Midjourney pass would double the spend. And it +cannot disagree with the portrait: it is the same drawing, which is the same argument +mj-queue makes for deriving prompts from statblocks. + + python3 tools/make-tokens.py every illustration without a token + python3 tools/make-tokens.py --force redo them all + python3 tools/make-tokens.py ... just these + +Input art/creatures/-u1.png (Midjourney, or any illustration) +Output art/tokens/.png 512x512 RGBA, circular, transparent surround +""" +import math +import sys +from pathlib import Path +from PIL import Image, ImageDraw, ImageFilter + +ROOT = Path(__file__).resolve().parent.parent +SRC, DST = ROOT / "art" / "creatures", ROOT / "art" / "tokens" +SIZE, RING = 512, 6 + +def subject_bbox(im, tol=236): + """Bounding box of everything that is not the plain ground. + + The plates are dark line work on near-white paper. Anything darker than `tol` in + greyscale is drawing. A median filter first, so paper speckle and MJ's signature + grain do not widen the box to the whole sheet — which is what happens with a naive + threshold and produces a token zoomed to nothing. + """ + g = im.convert("L").filter(ImageFilter.MedianFilter(5)) + mask = g.point(lambda p: 255 if p < tol else 0) + return mask.getbbox() + +def token_from(path, out): + im = Image.open(path).convert("RGBA") + box = subject_bbox(im) or (0, 0, im.width, im.height) + subject = im.crop(box) + # PAD to a square, never crop to one. + # + # These plates are 2:3 and the creature is usually taller than the sheet is wide, so + # a square crop clamped to the image clips whatever is at the top — which on the + # first gryphon token was its head. Build a square canvas instead, in the plate's own + # paper colour, and put the whole subject inside it. A token that is slightly small + # in its circle is a token; a token missing the animal's head is not. + # + # Size the canvas by the subject's DIAGONAL, not its longest side. + # + # A creature fits inside the circle exactly when sqrt(w^2 + h^2) <= diameter, so that + # is the number to use. Padding by the longest side instead (x1.5) is the safe-looking + # choice and it is badly wrong for these plates: the creatures are tall and narrow, so + # it wasted a third of the circle and left a gryphon the size of a thumbnail at battle + # -map scale. For a 300x900 subject the diagonal gives 996px against 1350 — the drawing + # lands about a third larger, with the same guarantee that nothing is clipped. + side = round(math.hypot(subject.width, subject.height) * 1.04) + paper = im.getpixel((2, 2)) + canvas = Image.new("RGBA", (side, side), paper) + canvas.paste(subject, ((side - subject.width) // 2, (side - subject.height) // 2), subject) + crop = canvas.resize((SIZE, SIZE), Image.LANCZOS) + + mask = Image.new("L", (SIZE, SIZE), 0) + ImageDraw.Draw(mask).ellipse((0, 0, SIZE - 1, SIZE - 1), fill=255) + crop.putalpha(mask) + + ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + ImageDraw.Draw(ring).ellipse((RING // 2, RING // 2, SIZE - 1 - RING // 2, SIZE - 1 - RING // 2), + outline=(26, 22, 18, 235), width=RING) + out.parent.mkdir(parents=True, exist_ok=True) + Image.alpha_composite(crop, ring).save(out, "PNG", optimize=True) + +def main(argv): + force = "--force" in argv + keys = [a for a in argv if not a.startswith("--")] + srcs = sorted(SRC.glob("*-u1.png")) + if keys: + srcs = [s for s in srcs if s.name[:-7] in keys] + made = skipped = 0 + for s in srcs: + key = s.name[:-7] # strip "-u1.png" + out = DST / f"{key}.png" + if out.exists() and not force: + skipped += 1 + continue + token_from(s, out) + made += 1 + print(f"make-tokens: wrote {made}, skipped {skipped} existing, of {len(srcs)} illustration(s)") + +if __name__ == "__main__": + main(sys.argv[1:])