210 lines
9.8 KiB
Python
210 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Build deterministic BAT-002 reconstruction evidence from accepted assets.
|
|
|
|
This tool creates review artifacts only: a layer-ownership board, a
|
|
color-coded dynamic-region audit mask and an asset provenance contact sheet.
|
|
It never edits or generates player-facing art.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PAGE = ROOT / "docs/design-authority/pages/BAT-002-live-battle"
|
|
APPROVED = PAGE / "approved/v001"
|
|
RECON = PAGE / "reconstruction/v001"
|
|
|
|
COLORS = {
|
|
"world": (47, 137, 171),
|
|
"rails": (111, 80, 166),
|
|
"actors": (204, 75, 84),
|
|
"event": (215, 139, 36),
|
|
"text": (31, 40, 47),
|
|
"controls": (46, 132, 101),
|
|
"panel": (245, 247, 244),
|
|
"ink": (23, 35, 43),
|
|
}
|
|
|
|
|
|
def font(size: int, bold: bool = False):
|
|
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()
|
|
|
|
|
|
REGIONS = [
|
|
("01", "PAINTED FIELD", [0.0, 0.0, 1.0, 1.0], "world", "static_master"),
|
|
("02", "TOP STATUS / PLAYBACK RAILS", [0.0, 0.0, 1.0, 0.125], "rails", "static_master + runtime"),
|
|
("03", "OBJECTIVE + URGENT CAUSE", [0.025, 0.135, 0.31, 0.13], "text", "runtime"),
|
|
("04", "COMPLETE ALLIED LINE", [0.03, 0.36, 0.52, 0.36], "actors", "runtime"),
|
|
("05", "CENTRAL ACTION GAP", [0.44, 0.28, 0.22, 0.42], "event", "runtime"),
|
|
("06", "COMPLETE ENEMY LINE", [0.56, 0.29, 0.42, 0.43], "actors", "runtime"),
|
|
("07", "EVENT / VALUE PROJECTION", [0.03, 0.25, 0.94, 0.16], "event", "runtime"),
|
|
("08", "BOTTOM ACTOR-STATE RAIL", [0.0, 0.80, 1.0, 0.20], "controls", "runtime"),
|
|
]
|
|
|
|
|
|
def fit(image: Image.Image, width: int, height: int, background: str = "#182830") -> Image.Image:
|
|
copy = image.convert("RGBA")
|
|
copy.thumbnail((width, height), Image.Resampling.LANCZOS)
|
|
canvas = Image.new("RGB", (width, height), background)
|
|
canvas.paste(copy, ((width - copy.width) // 2, (height - copy.height) // 2), copy)
|
|
return canvas
|
|
|
|
|
|
def wrap_text(text: str, limit: int) -> list[str]:
|
|
words = text.split()
|
|
lines: list[str] = []
|
|
current = ""
|
|
for word in words:
|
|
candidate = f"{current} {word}".strip()
|
|
if current and len(candidate) > limit:
|
|
lines.append(current)
|
|
current = word
|
|
else:
|
|
current = candidate
|
|
if current:
|
|
lines.append(current)
|
|
return lines
|
|
|
|
|
|
def build_board(source: Path, output: Path) -> None:
|
|
original = Image.open(source).convert("RGB")
|
|
image_w = 1160
|
|
image_h = round(original.height * image_w / original.width)
|
|
margin = 28
|
|
panel_w = 520
|
|
board = Image.new("RGB", (image_w + panel_w + margin * 3, max(image_h + margin * 2, 780)), COLORS["panel"])
|
|
board.paste(original.resize((image_w, image_h), Image.Resampling.LANCZOS), (margin, margin))
|
|
draw = ImageDraw.Draw(board, "RGBA")
|
|
draw.rectangle((margin, margin, margin + image_w, margin + image_h), outline=COLORS["ink"], width=3)
|
|
|
|
for number, label, bounds, color_name, owner in REGIONS:
|
|
x, y, width, height = bounds
|
|
left = margin + round(x * image_w)
|
|
top = margin + round(y * image_h)
|
|
right = margin + round((x + width) * image_w)
|
|
bottom = margin + round((y + height) * image_h)
|
|
color = COLORS[color_name]
|
|
draw.rectangle((left, top, right, bottom), outline=color + (220,), width=4)
|
|
draw.rounded_rectangle((left + 8, top + 8, left + min(340, max(150, 16 * len(label) + 78)), top + 40), 5, fill=color + (225,))
|
|
draw.text((left + 16, top + 13), f"{number} {label}", fill=(255, 255, 255), font=font(16, True))
|
|
|
|
panel_x = margin * 2 + image_w
|
|
draw.text((panel_x, margin), "BAT-002 / DECONSTRUCTION", fill=COLORS["text"], font=font(24, True))
|
|
draw.text((panel_x, margin + 39), "Approved composition is a coordinate reference; state stays in Godot.", fill=(92, 108, 114), font=font(13))
|
|
y = margin + 82
|
|
details = {
|
|
"01": "Existing Windglass field; editable static base, no actors or HUD baked.",
|
|
"02": "Continuous compact rails; shell may be rebuilt, values and actions are engine-owned.",
|
|
"03": "Objective, tick, rule, current cause and waiting/error copy are engine text.",
|
|
"04": "Exact ordered Party snapshot; ordinary scale and front-right position 0 stay visible.",
|
|
"05": "Forecast, source-to-target path, contact and protection projection; no drag lane.",
|
|
"06": "Exact committed enemy offer, including legal boss footprint and facing relation.",
|
|
"07": "Floating values and event sequence reserve; effects never cover actor anchors.",
|
|
"08": "Timer/rule plus ordered actor state cells; commands use separate transparent hit areas.",
|
|
}
|
|
for number, label, _bounds, color_name, owner in REGIONS:
|
|
color = COLORS[color_name]
|
|
draw.rectangle((panel_x, y + 2, panel_x + 34, y + 34), fill=color)
|
|
draw.text((panel_x + 8, y + 8), number, fill=(255, 255, 255), font=font(14, True))
|
|
draw.text((panel_x + 48, y), label, fill=COLORS["text"], font=font(14, True))
|
|
detail_lines = wrap_text(f"{owner}: {details[number]}", 54)
|
|
for line_index, line in enumerate(detail_lines):
|
|
draw.text((panel_x + 48, y + 21 + line_index * 14), line, fill=(92, 108, 114), font=font(10))
|
|
y += max(58, 25 + 14 * len(detail_lines))
|
|
|
|
y += 8
|
|
draw.text((panel_x, y), "FROZEN ANTI-GOALS", fill=COLORS["text"], font=font(15, True))
|
|
y += 26
|
|
for line in (
|
|
"Flattened approved PNG as Runtime background",
|
|
"Sparse four-actor or oversized ordinary-unit tableau",
|
|
"Separate event-card wall or parchment diagnostic panel",
|
|
"Baked names, values, controls, targets or pseudo-text",
|
|
"Pixel conversion, nearest-neighbor scaling or post-process pixelation",
|
|
):
|
|
draw.text((panel_x, y), "- " + line, fill=(92, 108, 114), font=font(11))
|
|
y += 24
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
board.save(output, format="PNG", optimize=True)
|
|
|
|
|
|
def build_mask(output: Path) -> None:
|
|
image = Image.new("RGB", (2048, 1152), (18, 29, 36))
|
|
draw = ImageDraw.Draw(image)
|
|
colors = {"world": (34, 56, 66), "rails": (115, 74, 165), "actors": (198, 67, 77), "event": (215, 139, 36), "text": (44, 137, 171), "controls": (46, 132, 101)}
|
|
for _number, _label, bounds, color_name, owner in REGIONS:
|
|
if owner == "static_master":
|
|
continue
|
|
x, y, width, height = bounds
|
|
rect = (round(x * 2048), round(y * 1152), round((x + width) * 2048) - 1, round((y + height) * 1152) - 1)
|
|
draw.rectangle(rect, fill=colors[color_name], outline=(235, 235, 220), width=2)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(output, format="PNG", optimize=True)
|
|
|
|
|
|
def build_contact_sheet(output: Path) -> None:
|
|
assets = [
|
|
("approved master / composition authority", APPROVED / "master.png"),
|
|
("scale proof / complete-line scale", APPROVED / "scale-proof.png"),
|
|
("clean static field / reused unchanged", RECON / "clean-static-master.png"),
|
|
("dynamic-region ownership mask", RECON / "dynamic-regions.png"),
|
|
("hearthguard / accepted Recruit", ROOT / "runtime/assets/actors/hearthguard.png"),
|
|
("wayrunner / accepted Recruit", ROOT / "runtime/assets/actors/wayrunner.png"),
|
|
("farstring / accepted Recruit", ROOT / "runtime/assets/actors/farstring.png"),
|
|
("kindhand / accepted Recruit", ROOT / "runtime/assets/actors/kindhand.png"),
|
|
("riftblade idle / enemy family", ROOT / "runtime/assets/enemies/riftblade/idle.png"),
|
|
("riftblade attack / enemy family", ROOT / "runtime/assets/enemies/riftblade/attack.png"),
|
|
("protect absorb / existing VFX", ROOT / "runtime/assets/vfx/protect_absorb.png"),
|
|
("damage heavy / existing VFX", ROOT / "runtime/assets/vfx/damage_heavy.png"),
|
|
]
|
|
width, tile_w, tile_h, margin = 1640, 390, 300, 32
|
|
rows = (len(assets) + 3) // 4
|
|
height = 110 + rows * (tile_h + 58) + margin
|
|
sheet = Image.new("RGB", (width, height), "#182830")
|
|
draw = ImageDraw.Draw(sheet)
|
|
draw.text((margin, 22), "BAT-002 ASSET CONTACT SHEET", fill="#f5e3a9", font=font(29, True))
|
|
draw.text((margin, 62), "No new image generation. Existing accepted assets and engine-owned projections only.", fill="#d3e0e2", font=font(14))
|
|
for index, (label, path) in enumerate(assets):
|
|
x = margin + (index % 4) * tile_w
|
|
y = 104 + (index // 4) * (tile_h + 58)
|
|
draw.rectangle((x, y, x + tile_w - 14, y + tile_h + 34), fill="#223943", outline="#b68c4b", width=2)
|
|
if path.is_file():
|
|
sheet.paste(fit(Image.open(path), tile_w - 28, tile_h - 28), (x + 7, y + 7))
|
|
draw.text((x + 12, y + tile_h + 8), label, fill="#f1eee0", font=font(12, True))
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
sheet.save(output, format="PNG", optimize=True)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--source", type=Path, default=APPROVED / "master.png")
|
|
parser.add_argument("--board", type=Path, default=RECON / "deconstruction-board.png")
|
|
parser.add_argument("--mask", type=Path, default=RECON / "dynamic-regions.png")
|
|
parser.add_argument("--contact", type=Path, default=RECON / "asset-contact-sheet.png")
|
|
args = parser.parse_args()
|
|
build_board(args.source, args.board)
|
|
build_mask(args.mask)
|
|
build_contact_sheet(args.contact)
|
|
print(f"BAT002_RECONSTRUCTION_ARTIFACTS_OK board={args.board} mask={args.mask} contact={args.contact}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|