Files
aetherbound-guild/tools/build_page_deconstruction_board.py
T

382 lines
20 KiB
Python

#!/usr/bin/env python3
"""Build a review board that makes a page's proposed layer ownership visible."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
COLORS = {
"world": (34, 139, 170),
"actors": (209, 78, 82),
"board": (213, 151, 35),
"top": (125, 82, 180),
"bottom": (39, 132, 94),
"text": (24, 33, 39),
"panel": (246, 247, 242),
}
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
candidates = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for candidate in candidates:
path = Path(candidate)
if path.is_file():
return ImageFont.truetype(str(path), size)
return ImageFont.load_default()
def build(source: Path, output: Path) -> None:
original = Image.open(source).convert("RGB")
source_w, source_h = original.size
image_w = 1120
image_h = round(source_h * image_w / source_w)
image = original.resize((image_w, image_h), Image.Resampling.LANCZOS)
panel_w = 430
margin = 28
board = Image.new("RGB", (image_w + panel_w + margin * 3, image_h + margin * 2), COLORS["panel"])
board.paste(image, (margin, margin))
draw = ImageDraw.Draw(board)
draw.rectangle((margin, margin, margin + image_w, margin + image_h), outline=(24, 33, 39), width=3)
def box(x0: float, y0: float, x1: float, y1: float, number: str, color: tuple[int, int, int]) -> None:
scaled = (margin + round(x0 * image_w), margin + round(y0 * image_h), margin + round(x1 * image_w), margin + round(y1 * image_h))
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle(scaled, outline=color + (235,), width=5)
odraw.rectangle((scaled[0], scaled[1], scaled[0] + 38, scaled[1] + 34), fill=color + (235,))
board.paste(overlay, (0, 0), overlay)
draw.text((scaled[0] + 10, scaled[1] + 5), number, fill=(255, 255, 255), font=font(20, True))
box(0.00, 0.00, 1.00, 1.00, "01", COLORS["world"])
box(0.00, 0.00, 1.00, 0.105, "02", COLORS["top"])
box(0.255, 0.405, 0.395, 0.875, "03", COLORS["actors"])
box(0.395, 0.385, 0.565, 0.865, "04", COLORS["actors"])
box(0.625, 0.285, 0.985, 0.875, "05", COLORS["board"])
box(0.00, 0.90, 1.00, 1.00, "06", COLORS["bottom"])
panel_x = margin * 2 + image_w
draw.text((panel_x, margin), "HUB-001 / DECONSTRUCTION PROPOSAL", fill=COLORS["text"], font=font(21, True))
draw.text((panel_x, margin + 38), "The image is a composition reference; dynamic copy stays in Godot.", fill=(102, 118, 123), font=font(14))
rows = [
("01", "PAINTED WORLD", "Static environment and depth. No gameplay state."),
("02", "TOP STATUS RAIL", "Guild identity, pressure, Coin and Company slots."),
("03", "RECRUIT A", "Persistent person-bearing unit; name, profession, condition."),
("04", "RECRUIT B", "Persistent person-bearing unit; same engine-owned bindings."),
("05", "CONTRACT BOARD", "Physical primary destination; title, brief, state and action."),
("06", "BOTTOM DESTINATION RAIL", "Roster, Equipment, Party, Risk, locked Market."),
]
y = margin + 84
for number, title, detail in rows:
color = COLORS[{"01": "world", "02": "top", "03": "actors", "04": "actors", "05": "board", "06": "bottom"}[number]]
draw.rectangle((panel_x, y + 2, panel_x + 30, y + 32), fill=color)
draw.text((panel_x + 8, y + 6), number, fill=(255, 255, 255), font=font(15, True))
draw.text((panel_x + 44, y), title, fill=COLORS["text"], font=font(15, True))
draw.text((panel_x + 44, y + 21), detail, fill=(102, 118, 123), font=font(12))
y += 62
y += 12
draw.text((panel_x, y), "REVIEW QUESTIONS", fill=COLORS["text"], font=font(15, True))
questions = [
"Are the two actors the right persistent Company subject?",
"Does the board own the next action without becoming a side card?",
"Are the top and bottom rails compact enough at 760x360?",
"Are all blank regions large enough for zh/en engine text at 130%?",
]
y += 28
for question in questions:
draw.text((panel_x, y), "- " + question, fill=(102, 118, 123), font=font(12))
y += 27
output.parent.mkdir(parents=True, exist_ok=True)
board.save(output, format="PNG", optimize=True)
def build_party_line(source: Path, output: Path) -> None:
"""Build the PTY-001 review board with Party-specific layer ownership."""
original = Image.open(source).convert("RGB")
source_w, source_h = original.size
image_w = 1120
image_h = round(source_h * image_w / source_w)
image = original.resize((image_w, image_h), Image.Resampling.LANCZOS)
panel_w = 470
margin = 28
board = Image.new("RGB", (image_w + panel_w + margin * 3, image_h + margin * 2), COLORS["panel"])
board.paste(image, (margin, margin))
draw = ImageDraw.Draw(board)
draw.rectangle((margin, margin, margin + image_w, margin + image_h), outline=COLORS["text"], width=3)
def box(x0: float, y0: float, x1: float, y1: float, number: str, color: tuple[int, int, int]) -> None:
scaled = (
margin + round(x0 * image_w),
margin + round(y0 * image_h),
margin + round(x1 * image_w),
margin + round(y1 * image_h),
)
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle(scaled, outline=color + (235,), width=5)
odraw.rectangle((scaled[0], scaled[1], scaled[0] + 38, scaled[1] + 34), fill=color + (235,))
board.paste(overlay, (0, 0), overlay)
draw.text((scaled[0] + 10, scaled[1] + 5), number, fill=(255, 255, 255), font=font(20, True))
box(0.00, 0.10, 1.00, 0.87, "01", COLORS["world"])
box(0.00, 0.00, 1.00, 0.12, "02", COLORS["top"])
box(0.08, 0.47, 0.88, 0.64, "03", COLORS["board"])
box(0.19, 0.28, 0.72, 0.69, "04", COLORS["actors"])
box(0.36, 0.65, 0.68, 0.83, "05", (34, 139, 170))
box(0.00, 0.87, 1.00, 1.00, "06", COLORS["bottom"])
box(0.60, 0.45, 0.84, 0.76, "07", (213, 151, 35))
panel_x = margin * 2 + image_w
draw.text((panel_x, margin), "PTY-001 / DECONSTRUCTION PROPOSAL", fill=COLORS["text"], font=font(21, True))
draw.text((panel_x, margin + 38), "The approved draft is a composition reference; runtime state stays separate.", fill=(102, 118, 123), font=font(13))
rows = [
("01", "PAINTED TRAINING YARD", "Static depth world reused from accepted Guild provenance.", COLORS["world"]),
("02", "TOP STATUS RAIL", "Party identity, Pressure and Company engine slots.", COLORS["top"]),
("03", "FORMATION CORD", "Rear-left to front-right line; static art without numbers.", COLORS["board"]),
("04", "FOUR RUNTIME RECRUITS", "Company-bound actors, foot markers and engine position values.", COLORS["actors"]),
("05", "RELATION PREVIEW", "Protection, reach, threat and target deltas rendered by Godot.", (34, 139, 170)),
("06", "COMMAND RAIL", "Undo, Reset, Save Order and Back; icon art plus hit areas.", COLORS["bottom"]),
("07", "SELECTION PATH", "Selected actor ring, legal destination and preview path.", (213, 151, 35)),
]
y = margin + 82
for number, title, detail, color in rows:
draw.rectangle((panel_x, y + 2, panel_x + 30, y + 32), fill=color)
draw.text((panel_x + 8, y + 6), number, fill=(255, 255, 255), font=font(15, True))
draw.text((panel_x + 44, y), title, fill=COLORS["text"], font=font(14, True))
draw.text((panel_x + 44, y + 20), detail, fill=(102, 118, 123), font=font(11))
y += 55
y += 8
draw.text((panel_x, y), "REVIEW QUESTIONS", fill=COLORS["text"], font=font(15, True))
questions = [
"Do all four actors read together at 760x360?",
"Is position 0 unmistakably exposed at front-right?",
"Does the relation preview explain change without becoming a card wall?",
"Are the four command icons distinct and attached to the bottom rail?",
]
y += 26
for question in questions:
draw.text((panel_x, y), "- " + question, fill=(102, 118, 123), font=font(11))
y += 24
output.parent.mkdir(parents=True, exist_ok=True)
board.save(output, format="PNG", optimize=True)
def build_readiness(source: Path, output: Path) -> None:
"""Build the PTY-002 frozen-snapshot layer ownership board."""
original = Image.open(source).convert("RGB")
source_w, source_h = original.size
image_w = 1120
image_h = round(source_h * image_w / source_w)
image = original.resize((image_w, image_h), Image.Resampling.LANCZOS)
panel_w = 510
margin = 28
board = Image.new("RGB", (image_w + panel_w + margin * 3, image_h + margin * 2), COLORS["panel"])
board.paste(image, (margin, margin))
draw = ImageDraw.Draw(board)
draw.rectangle((margin, margin, margin + image_w, margin + image_h), outline=COLORS["text"], width=3)
def box(x0: float, y0: float, x1: float, y1: float, number: str, color: tuple[int, int, int]) -> None:
scaled = (margin + round(x0 * image_w), margin + round(y0 * image_h), margin + round(x1 * image_w), margin + round(y1 * image_h))
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle(scaled, outline=color + (235,), width=5)
odraw.rectangle((scaled[0], scaled[1], scaled[0] + 42, scaled[1] + 34), fill=color + (235,))
board.paste(overlay, (0, 0), overlay)
draw.text((scaled[0] + 11, scaled[1] + 5), number, fill=(255, 255, 255), font=font(19, True))
box(0.00, 0.12, 1.00, 0.86, "01", COLORS["world"])
box(0.00, 0.00, 1.00, 0.13, "02", COLORS["top"])
box(0.08, 0.47, 0.82, 0.60, "03", COLORS["board"])
box(0.18, 0.28, 0.73, 0.62, "04", COLORS["actors"])
box(0.04, 0.63, 0.76, 0.82, "05", (34, 139, 170))
box(0.78, 0.56, 0.99, 0.78, "06", (209, 78, 82))
box(0.00, 0.84, 1.00, 1.00, "07", COLORS["bottom"])
box(0.76, 0.27, 0.99, 0.50, "08", (102, 118, 123))
panel_x = margin * 2 + image_w
draw.text((panel_x, margin), "PTY-002 / DECONSTRUCTION", fill=COLORS["text"], font=font(21, True))
draw.text((panel_x, margin + 38), "Frozen review only; all values and copy stay in Godot.", fill=(102, 118, 123), font=font(13))
rows = [
("01", "PAINTED COURTYARD", "Static world and depth; no party state.", COLORS["world"]),
("02", "TOP STATUS RAIL", "Readiness identity, snapshot version and Company slot.", COLORS["top"]),
("03", "FORMATION RELATION LINE", "Static directional relation; no baked positions or values.", COLORS["board"]),
("04", "FROZEN COMPANY ACTORS", "Same four Company IDs and order from PTY-001; no reorder here.", COLORS["actors"]),
("05", "READINESS SUMMARY", "Protection, reach, pressure coverage and Reserve engine slots.", (34, 139, 170)),
("06", "WARNING STACK", "Severity/owner facts; actionable rows route to the owning page.", (209, 78, 82)),
("07", "COMMAND RAIL", "Revise, Back, primary Accept Preparation and post-accept route.", COLORS["bottom"]),
("08", "RISK NEXT STATE", "Unavailable until Accept; never a battle command on this page.", (102, 118, 123)),
]
y = margin + 82
for number, title, detail, color in rows:
draw.rectangle((panel_x, y + 2, panel_x + 32, y + 32), fill=color)
draw.text((panel_x + 8, y + 6), number, fill=(255, 255, 255), font=font(14, True))
draw.text((panel_x + 46, y), title, fill=COLORS["text"], font=font(13, True))
draw.text((panel_x + 46, y + 20), detail, fill=(102, 118, 123), font=font(10))
y += 52
y += 8
draw.text((panel_x, y), "REVIEW QUESTIONS", fill=COLORS["text"], font=font(15, True))
questions = [
"Does the page read as frozen review, not editable formation?",
"Do actors, front exposure and relation line remain first read?",
"Are warnings grouped without covering the world or actors?",
"Is Accept clearly primary and Risk visibly a later boundary?",
]
y += 26
for question in questions:
draw.text((panel_x, y), "- " + question, fill=(102, 118, 123), font=font(10))
y += 23
output.parent.mkdir(parents=True, exist_ok=True)
board.save(output, format="PNG", optimize=True)
def build_top_rail(source: Path, output: Path) -> None:
"""Build a close review board for the nested top status rail."""
original = Image.open(source).convert("RGB")
crop = original.crop((0, 0, original.width, round(original.height * 0.155)))
crop = crop.resize((2048, 260), Image.Resampling.LANCZOS)
panel_w = 560
margin = 24
board_height = max(crop.height + margin * 2, 540)
board = Image.new("RGB", (crop.width + panel_w + margin * 3, board_height), COLORS["panel"])
board.paste(crop, (margin, margin))
draw = ImageDraw.Draw(board)
draw.rectangle((margin, margin, margin + crop.width, margin + crop.height), outline=COLORS["text"], width=3)
def box(x0: int, x1: int, number: str, color: tuple[int, int, int]) -> None:
left = margin + x0
right = margin + x1
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle((left, margin, right, margin + crop.height), outline=color + (235,), width=5)
odraw.rectangle((left, margin, left + 42, margin + 34), fill=color + (235,))
board.paste(overlay, (0, 0), overlay)
draw.text((left + 10, margin + 5), number, fill=(255, 255, 255), font=font(20, True))
box(0, 820, "02A", (34, 139, 170))
box(55, 175, "02B", (125, 82, 180))
box(190, 710, "02C", (39, 132, 94))
box(1210, 1455, "02D", (209, 78, 82))
box(1460, 1710, "02E", (213, 151, 35))
box(1715, 1900, "02F", (39, 132, 94))
box(1905, 2048, "02G", (102, 118, 123))
panel_x = margin * 2 + crop.width
draw.text((panel_x, margin), "02 / TOP RAIL SUB-DECOMPOSITION", fill=COLORS["text"], font=font(20, True))
draw.text((panel_x, margin + 38), "Frame, icons and engine slots have different ownership.", fill=(102, 118, 123), font=font(13))
rows = [
("02A", "RAIL SHELL", "Static segmented/NinePatch frame; stretches only in the center."),
("02B", "GUILD CREST", "Fixed square static emblem; no state text."),
("02C", "IDENTITY SLOT", "Engine-rendered Guild name; bounded and localized."),
("02D", "PRESSURE CELL", "Static icon plus engine value and state styling."),
("02E", "COIN CELL", "Static icon plus engine integer value."),
("02F", "COMPANY CELL", "Static people icon plus engine count."),
("02G", "SETTINGS", "Static icon plus transparent action hit area."),
]
y = margin + 82
for number, title, detail in rows:
color = (34, 139, 170) if number == "02A" else (125, 82, 180) if number == "02B" else (39, 132, 94) if number in {"02C", "02F"} else (209, 78, 82) if number == "02D" else (213, 151, 35) if number == "02E" else (102, 118, 123)
draw.rectangle((panel_x, y + 2, panel_x + 38, y + 32), fill=color)
draw.text((panel_x + 4, y + 6), number, fill=(255, 255, 255), font=font(13, True))
draw.text((panel_x + 50, y), title, fill=COLORS["text"], font=font(14, True))
draw.text((panel_x + 50, y + 20), detail, fill=(102, 118, 123), font=font(11))
y += 56
output.parent.mkdir(parents=True, exist_ok=True)
board.save(output, format="PNG", optimize=True)
def build_market(source: Path, output: Path) -> None:
"""Build the SHP-001 Market layer-ownership review board."""
original = Image.open(source).convert("RGB")
source_w, source_h = original.size
image_w = 1120
image_h = round(source_h * image_w / source_w)
image = original.resize((image_w, image_h), Image.Resampling.LANCZOS)
panel_w = 540
margin = 28
board = Image.new("RGB", (image_w + panel_w + margin * 3, image_h + margin * 2), COLORS["panel"])
board.paste(image, (margin, margin))
draw = ImageDraw.Draw(board)
draw.rectangle((margin, margin, margin + image_w, margin + image_h), outline=COLORS["text"], width=3)
def box(x0: float, y0: float, x1: float, y1: float, number: str, color: tuple[int, int, int]) -> None:
scaled = (margin + round(x0 * image_w), margin + round(y0 * image_h), margin + round(x1 * image_w), margin + round(y1 * image_h))
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
odraw = ImageDraw.Draw(overlay)
odraw.rectangle(scaled, outline=color + (235,), width=5)
odraw.rectangle((scaled[0], scaled[1], scaled[0] + 42, scaled[1] + 34), fill=color + (235,))
board.paste(overlay, (0, 0), overlay)
draw.text((scaled[0] + 11, scaled[1] + 5), number, fill=(255, 255, 255), font=font(19, True))
box(0.00, 0.12, 0.78, 0.84, "01", COLORS["world"])
box(0.02, 0.02, 0.40, 0.18, "02", COLORS["top"])
box(0.15, 0.30, 0.31, 0.79, "03", COLORS["actors"])
box(0.31, 0.30, 0.53, 0.79, "04", COLORS["actors"])
box(0.53, 0.30, 0.77, 0.79, "05", COLORS["actors"])
box(0.78, 0.02, 0.995, 0.84, "06", COLORS["board"])
box(0.00, 0.84, 0.78, 1.00, "07", COLORS["bottom"])
box(0.78, 0.78, 0.995, 1.00, "08", (39, 132, 94))
panel_x = margin * 2 + image_w
draw.text((panel_x, margin), "SHP-001 / DECONSTRUCTION", fill=COLORS["text"], font=font(21, True))
draw.text((panel_x, margin + 38), "Composition is frozen; values and copy stay in Godot.", fill=(102, 118, 123), font=font(13))
rows = [
("01", "PAINTED REQUISITION WORLD", "Static world and counter depth; no Market state.", COLORS["world"]),
("02", "COMPANY / CAPACITY RAIL", "Owned count and Line capacity are separate engine slots.", COLORS["top"]),
("03", "RECRUIT OFFER A", "Equal person-bearing offer; stable ID, Profession, Trait and price.", COLORS["actors"]),
("04", "RECRUIT OFFER B", "Equal person-bearing offer; focus is local and never recommendation.", COLORS["actors"]),
("05", "RECRUIT OFFER C", "Equal person-bearing offer; no baked identity or purchase.", COLORS["actors"]),
("06", "EDGE LEDGER", "Coin, mode, refresh and lock facts; inspect-only at SHP-001.", COLORS["board"]),
("07", "EQUIPMENT TRAY", "Twelve sockets; four item objects and empty engine-owned slots.", COLORS["bottom"]),
("08", "REVIEW BOUNDARY", "One attached Review action opens SHP-002 or equipment review.", (39, 132, 94)),
]
y = margin + 82
for number, title, detail, color in rows:
draw.rectangle((panel_x, y + 2, panel_x + 34, y + 32), fill=color)
draw.text((panel_x + 8, y + 6), number, fill=(255, 255, 255), font=font(14, True))
draw.text((panel_x + 46, y), title, fill=COLORS["text"], font=font(13, True))
draw.text((panel_x + 46, y + 20), detail, fill=(102, 118, 123), font=font(10))
y += 50
y += 8
draw.text((panel_x, y), "REVIEW QUESTIONS", fill=COLORS["text"], font=font(15, True))
questions = [
"Do the three offers read as equal ordinary people at 760x360?",
"Can Company count and Line capacity be read independently?",
"Does the counter/tray/ledger stay physical instead of becoming cards?",
"Is Review the only strong boundary while list taps stay inspect-only?",
]
y += 26
for question in questions:
draw.text((panel_x, y), "- " + question, fill=(102, 118, 123), font=font(10))
y += 23
output.parent.mkdir(parents=True, exist_ok=True)
board.save(output, format="PNG", optimize=True)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--top-rail-output", type=Path)
parser.add_argument("--page-type", choices=("hub", "party", "readiness", "market"), default="hub")
args = parser.parse_args()
if args.page_type == "market":
build_market(args.source, args.output)
elif args.page_type == "party":
build_party_line(args.source, args.output)
elif args.page_type == "readiness":
build_readiness(args.source, args.output)
else:
build(args.source, args.output)
if args.top_rail_output:
build_top_rail(args.source, args.top_rail_output)
if __name__ == "__main__":
main()