Tokens: derive them from the illustration, and size them by SIZ
Three faults, all in how an actor reaches the table. THE TOKEN WAS THE PORTRAIT. prototypeToken.texture pointed at the portrait file, which is a 2:3 field-guide plate — full figure on plain ground. Foundry draws a token square, so it was squeezing a tall plate into it and showing a torso. tools/make-tokens.py now derives a 512px circular RGBA token from the same illustration: not a second Midjourney pass, because that would double the spend and still give a token that does not quite match its own portrait. Sized by the subject's DIAGONAL, since these creatures are tall and narrow and padding by the longest side wasted a third of the circle. Padded, never cropped — cropping to a square clipped the first gryphon's head off. EVERY TOKEN WAS 1x1. A hearth hob, a carthorse and a SIZ 32 dun cow all held one square. Footprint now comes from SIZ, cut at 21 and 30: 132 at 1x1, 14 at 2x2, 1 at 3x3. THE 100 CREATURES WERE IN NO COMPENDIUM. build-packs reads NPCS directly rather than collectSpecs(), so the bestiary validated, was drawn, had prompts written, and did not exist in the game. The npcs pack held 47 actors; it holds 147 now. That is the fourth consumer found bypassing all-specs.mjs, whose own header calls one-definition-only "this repository's one standing law" — bestiary.mjs and check-lethality.mjs still do. check-lethality is the one that matters: it cannot see the new creatures, so it is measuring 47 of 147 while reporting OK. All 23 guards pass. npm run build is red at update-readme for an unrelated pre-existing reason: the README advertises a guard list that package.json no longer matches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
00f6a1e30f
commit
8a591b7853
+2
-1
@@ -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)",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
MANIFEST-000002
|
||||
MANIFEST-000004
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40
-2
@@ -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]);
|
||||
|
||||
@@ -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 <key> ... just these
|
||||
|
||||
Input art/creatures/<key>-u1.png (Midjourney, or any illustration)
|
||||
Output art/tokens/<key>.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:])
|
||||
Reference in New Issue
Block a user