mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-17 14:00:25 +02:00
304 lines
14 KiB
Python
304 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Makes high-res textures of the menus' controller button icons
|
|
(port/assets/buttons/*.png) from hand-drawn SVG redraws of them
|
|
(port/assets/buttons/svg):
|
|
|
|
python tools/button_assets.py layout --map assets/maps/ui.map
|
|
python tools/button_assets.py build
|
|
python tools/button_assets.py check --map assets/maps/ui.map --out /tmp/button_check
|
|
|
|
The icons beside the menus' key labels (A SELECT, B BACK, X DELETE, Y CREATE
|
|
NEW) are 32x32 bitmaps of ui.map with their art in the top left corner,
|
|
which the widgets' backgrounds draw at one texel to the pixel
|
|
(draw_bitmap_in_rect crops a bitmap larger than its widget, in coordinates
|
|
of the bitmap's size); the dialogs, the pause menu and the help screens draw
|
|
smaller A and B icons, bitmaps of their own in every map. So a texture of 8x
|
|
a bitmap's size, in its layout, draws in its place unchanged, as the
|
|
high-res HUD's do (port/linux/src/hud_hires.c), and with the high-res text
|
|
(display.high_res_text), as the menus' titles do.
|
|
|
|
Each redraw is drawn in the texels (viewBox 0 0 32 32) of the large icon it
|
|
is named after, over the map's art: a tilted oval with a pale rim, dark at
|
|
the top and bright at the bottom, and its letter in white (Y's casting a
|
|
dark shadow). A small icon is the same art smaller.
|
|
|
|
The menus' text draws the same buttons as message icons ("Press %a-button
|
|
to Join": draw_string_and_hack_in_icons, ui_widget.c), sprites of a sheet
|
|
of every map, hud_msg_icons_sm, that the game tints: stencils, each oval in
|
|
white with its letter cut out. They are drawn from the redraws' oval and
|
|
letter (their ids "oval" and "letter"), into a texture of the sheet's
|
|
layout that has only those sprites: the sheet's other icons (triggers and
|
|
sticks, with English words) keep the map's, so the texture stands for the
|
|
sprites alone, and the game draws them from it through a placeholder
|
|
(port/linux/game/hud_hires_tags.c).
|
|
|
|
layout: reads each icon's bitmap from the map (its size, format and the CRC
|
|
of its pixels, which the game checks before drawing the texture in its
|
|
place) and places its redraw on it: a small icon's scaled and moved as
|
|
the map's small art is from its large art (so that their shapes' area
|
|
and centre are the same), and a message icon's stencil where it covers
|
|
the sprite's best. Writes port/assets/buttons/buttons.json.
|
|
build: renders the redraws as buttons.json places them into
|
|
port/assets/buttons/*.png (committed; the builds embed them:
|
|
tools/embed_assets.py).
|
|
check: compares each PNG, reduced to its bitmap's size, with the map's
|
|
bitmap and writes side-by-side images into --out.
|
|
|
|
Needs rsvg-convert, Pillow, NumPy and SciPy.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import xml.etree.ElementTree as ElementTree
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from hud_assets import (FORMATS, XboxMap, bleed, decode_bitmap, level0_size, overlap, # noqa: E402
|
|
pixel_rectangle, render_svg)
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ASSETS = ROOT / "port/assets/buttons"
|
|
LIST = ASSETS / "buttons.json"
|
|
SCALE = 8
|
|
|
|
BITMAPS = "ui\\shell\\bitmaps\\"
|
|
# each icon's bitmap and its redraw (drawn over the bitmap it is named
|
|
# after): the button keys' (ui.map), and the smaller ones of the dialogs,
|
|
# the pause menu and the help screens (every map)
|
|
BUTTONS = {
|
|
BITMAPS + "a_butn": "a_butn.svg",
|
|
BITMAPS + "b_butn": "b_butn.svg",
|
|
BITMAPS + "x_butn": "x_butn.svg",
|
|
BITMAPS + "y_butn": "y_butn.svg",
|
|
BITMAPS + "a_butn_sm": "a_butn.svg",
|
|
BITMAPS + "b_butn_sm": "b_butn.svg",
|
|
}
|
|
# the message icons' sheet, and the redraw of each of its buttons, by the
|
|
# sequence of its sprite (on the sheet's first bitmap)
|
|
MESSAGE_ICONS = "ui\\hud\\bitmaps\\hud_msg_icons_sm"
|
|
MESSAGE_BUTTONS = {0: "a_butn.svg", 1: "b_butn.svg", 2: "x_butn.svg", 3: "y_butn.svg"}
|
|
|
|
|
|
def document(width: int, height: int, body: str) -> str:
|
|
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
f'viewBox="0 0 {width} {height}">\n{body}\n</svg>\n')
|
|
|
|
|
|
def placed(svg: Path, width: int, height: int, place: list) -> str:
|
|
"""The redraw as a picture of its bitmap's size, scaled by place[0] and
|
|
moved by place[1:] (texels)."""
|
|
body = re.search(r"<svg\b[^>]*>(.*)</svg>", svg.read_text(), re.S).group(1)
|
|
scale, x, y = place
|
|
return document(width, height, f'<g transform="translate({x} {y}) scale({scale})">{body}</g>')
|
|
|
|
|
|
def stencil(svg: str, cell: list, place: list, index: int) -> str:
|
|
"""A redraw as the message icons draw its button, in a sprite's cell
|
|
(texels of its sheet): its oval in white with its letter cut out,
|
|
scaled by place[0] and moved by place[1:] from the cell's corner, and
|
|
kept to the cell; index names its clip and mask."""
|
|
root = ElementTree.parse(ASSETS / "svg" / svg).getroot()
|
|
parents = {child: parent for parent in root.iter() for child in parent}
|
|
oval, letter = root.find(".//*[@id='oval']"), root.find(".//*[@id='letter']")
|
|
left, top, right, bottom = cell
|
|
scale, x, y = place
|
|
moved = f"translate({left + x} {top + y}) scale({scale})"
|
|
area = f'x="{left}" y="{top}" width="{right - left}" height="{bottom - top}"'
|
|
return (f'<defs><clipPath id="cell{index}"><rect {area}/></clipPath>\n'
|
|
f'<mask id="letter{index}" maskUnits="userSpaceOnUse" {area}><rect {area} fill="#ffffff"/>\n'
|
|
f'<path transform="{moved} {letter.get("transform")}" fill="#000000" d="{letter.get("d")}"/></mask></defs>\n'
|
|
f'<g clip-path="url(#cell{index})" mask="url(#letter{index})">'
|
|
f'<ellipse transform="{moved} {parents[oval].get("transform")}" rx="{oval.get("rx")}" '
|
|
f'ry="{oval.get("ry")}" fill="#ffffff"/></g>')
|
|
|
|
|
|
def render(text: str, scale: int) -> np.ndarray:
|
|
"""An SVG document at scale times its size."""
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "placed.svg"
|
|
path.write_text(text)
|
|
return render_svg(path, scale)
|
|
|
|
|
|
def icon(xbox_map: XboxMap, tag: str) -> dict:
|
|
"""An icon's bitmap (its group's only one)."""
|
|
if ("bitm", tag) not in xbox_map.tags:
|
|
sys.exit(f"{tag}: not in this map")
|
|
bitmaps = xbox_map.bitmap_group(tag)["bitmaps"]
|
|
if len(bitmaps) != 1:
|
|
sys.exit(f"{tag}: {len(bitmaps)} bitmaps, not the icon's one")
|
|
return bitmaps[0]
|
|
|
|
|
|
def shape(alpha: np.ndarray, scale: int = 1) -> tuple:
|
|
"""The area and centre of a shape, in texels, from its alpha drawn at
|
|
scale times the texels."""
|
|
alpha = alpha.astype(float) / 255
|
|
ys, xs = np.mgrid[0:alpha.shape[0], 0:alpha.shape[1]]
|
|
area = alpha.sum()
|
|
return (area / scale ** 2, ((xs + 0.5) * alpha).sum() / area / scale,
|
|
((ys + 0.5) * alpha).sum() / area / scale)
|
|
|
|
|
|
def stencil_place(svg: str, alpha: np.ndarray) -> list:
|
|
"""Where a redraw's stencil goes in a sprite's cell, whose alpha it is:
|
|
where it covers it best, from where it has its area and centre."""
|
|
from scipy import optimize
|
|
|
|
height, width = alpha.shape
|
|
root = ElementTree.parse(ASSETS / "svg" / svg).getroot()
|
|
size = int(root.get("width")), int(root.get("height"))
|
|
area, x, y = shape(render(document(*size, stencil(svg, [0, 0, *size], [1, 0, 0], 0)), SCALE)[..., 3], SCALE)
|
|
sprite_area, sprite_x, sprite_y = shape(alpha)
|
|
scale = (sprite_area / area) ** 0.5
|
|
target = alpha.astype(float) / 255
|
|
|
|
def error(place: np.ndarray) -> float:
|
|
drawn = render(document(width, height, stencil(svg, [0, 0, width, height], list(place), 0)), SCALE)
|
|
reduced = drawn[..., 3].astype(float).reshape(height, SCALE, width, SCALE).mean(axis=(1, 3)) / 255
|
|
return float(((reduced - target) ** 2).sum())
|
|
|
|
best = optimize.minimize(error, [scale, sprite_x - scale * x, sprite_y - scale * y], method="Powell",
|
|
options={"xtol": 1e-3, "ftol": 1e-7}).x
|
|
return [round(float(best[0]), 4), round(float(best[1]), 3), round(float(best[2]), 3)]
|
|
|
|
|
|
def layout(arguments) -> None:
|
|
xbox_map = XboxMap(Path(arguments.map))
|
|
entries = []
|
|
for tag, svg in BUTTONS.items():
|
|
bitmap = icon(xbox_map, tag)
|
|
width, height = bitmap["width"], bitmap["height"]
|
|
drawn_over = BITMAPS + Path(svg).stem
|
|
place = [1, 0, 0]
|
|
if drawn_over != tag:
|
|
area, x, y = shape(decode_bitmap(bitmap)[..., 3])
|
|
drawn_area, drawn_x, drawn_y = shape(decode_bitmap(icon(xbox_map, drawn_over))[..., 3])
|
|
scale = (area / drawn_area) ** 0.5
|
|
place = [round(scale, 4), round(x - scale * drawn_x, 3), round(y - scale * drawn_y, 3)]
|
|
entries.append({
|
|
"name": tag.split("\\")[-1] + "__0",
|
|
"tag": tag,
|
|
"bitmap": 0,
|
|
"width": width,
|
|
"height": height,
|
|
"format": FORMATS[bitmap["format"]],
|
|
"scale": SCALE,
|
|
# (the map's, which the game checks before drawing this in its
|
|
# place: other languages' maps and modified ones may differ)
|
|
"crc": zlib.crc32(bitmap["pixels"][:level0_size(bitmap)]),
|
|
"svg": svg,
|
|
"place": place,
|
|
})
|
|
print(f"{tag}: {width}x{height} {FORMATS[bitmap['format']]}, {svg} at {place[0]}x, moved {place[1]}, {place[2]}")
|
|
# the message icons' buttons
|
|
if ("bitm", MESSAGE_ICONS) not in xbox_map.tags:
|
|
sys.exit(f"{MESSAGE_ICONS}: not in this map")
|
|
group = xbox_map.bitmap_group(MESSAGE_ICONS)
|
|
bitmap = group["bitmaps"][0]
|
|
width, height = bitmap["width"], bitmap["height"]
|
|
alpha = decode_bitmap(bitmap)[..., 3]
|
|
sprites = []
|
|
for sequence, svg in MESSAGE_BUTTONS.items():
|
|
cells = group["sequences"][sequence]
|
|
if len(cells) != 1 or cells[0][0] != 0:
|
|
sys.exit(f"{MESSAGE_ICONS}: sequence {sequence} is not one sprite of its first bitmap")
|
|
cell = pixel_rectangle(cells[0], width, height)
|
|
place = stencil_place(svg, alpha[cell[1]:cell[3], cell[0]:cell[2]])
|
|
sprites.append({"sequence": sequence, "cell": cell, "svg": svg, "place": place})
|
|
print(f"{MESSAGE_ICONS} sequence {sequence}: cell {cell}, {svg} at {place[0]}x, moved {place[1]}, {place[2]}")
|
|
entries.append({
|
|
"name": MESSAGE_ICONS.split("\\")[-1] + "__0",
|
|
"tag": MESSAGE_ICONS,
|
|
"bitmap": 0,
|
|
"width": width,
|
|
"height": height,
|
|
"format": FORMATS[bitmap["format"]],
|
|
"scale": SCALE,
|
|
"crc": zlib.crc32(bitmap["pixels"][:level0_size(bitmap)]),
|
|
# (the sprites it stands for, not the whole bitmap)
|
|
"sprites": sprites,
|
|
})
|
|
LIST.write_text(json.dumps({"assets": entries}, indent=1) + "\n")
|
|
|
|
|
|
def build(arguments) -> None:
|
|
description = json.loads(LIST.read_text())
|
|
names = {f"{entry['name']}.png" for entry in description["assets"]}
|
|
for stale in ASSETS.glob("*.png"):
|
|
if stale.name not in names:
|
|
stale.unlink()
|
|
for entry in description["assets"]:
|
|
if "sprites" in entry:
|
|
body = "\n".join(stencil(sprite["svg"], sprite["cell"], sprite["place"], index)
|
|
for index, sprite in enumerate(entry["sprites"]))
|
|
image = render(document(entry["width"], entry["height"], body), entry["scale"])
|
|
# (grey the same as alpha, as the sheet's)
|
|
image[..., :3] = image[..., 3:4]
|
|
else:
|
|
image = render(placed(ASSETS / "svg" / entry["svg"], entry["width"], entry["height"], entry["place"]),
|
|
entry["scale"])
|
|
# (the colour carried into the transparent texels, so that
|
|
# filtering and the mip levels keep it at the oval's edge)
|
|
image = bleed(image)
|
|
Image.fromarray(image, "RGBA").save(ASSETS / f"{entry['name']}.png", optimize=True)
|
|
print(f"{entry['name']}.png: {image.shape[1]}x{image.shape[0]}")
|
|
|
|
|
|
def check(arguments) -> None:
|
|
xbox_map = XboxMap(Path(arguments.map))
|
|
output = Path(arguments.out)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
for entry in json.loads(LIST.read_text())["assets"]:
|
|
if ("bitm", entry["tag"]) not in xbox_map.tags:
|
|
print(f"{entry['name']}: not in this map")
|
|
continue
|
|
bitmap = xbox_map.bitmap_group(entry["tag"])["bitmaps"][entry["bitmap"]]
|
|
if zlib.crc32(bitmap["pixels"][:level0_size(bitmap)]) != entry["crc"]:
|
|
print(f"{entry['name']}: this map's bitmap is not the one laid out")
|
|
xbox = decode_bitmap(bitmap)
|
|
image = Image.open(ASSETS / f"{entry['name']}.png").convert("RGBA")
|
|
reduced = np.asarray(image.resize((entry["width"], entry["height"]), Image.BOX))
|
|
scale = entry["scale"]
|
|
# (each sprite's cell, or the whole bitmap)
|
|
cells = [sprite["cell"] for sprite in entry.get("sprites", [])] or [[0, 0, entry["width"], entry["height"]]]
|
|
for index, (left, top, right, bottom) in enumerate(cells):
|
|
score = overlap(reduced[top:bottom, left:right, 3].astype(float), xbox[top:bottom, left:right, 3].astype(float))
|
|
name = entry["name"] + (f"__sprite{index}" if "sprites" in entry else "")
|
|
print(f"{name}: alpha overlap {score:.2f}")
|
|
# the map's | ours reduced | ours, over the menus' dark blue
|
|
ours = image.crop((left * scale, top * scale, right * scale, bottom * scale))
|
|
panels = [Image.fromarray(np.ascontiguousarray(picture[top:bottom, left:right]), "RGBA").resize(
|
|
ours.size, Image.NEAREST) for picture in (xbox, reduced)]
|
|
panels.append(ours)
|
|
width, height = ours.size
|
|
sheet = Image.new("RGB", (width * 3 + 16, height), (255, 0, 0))
|
|
for place, picture in enumerate(panels):
|
|
background = Image.new("RGBA", picture.size, (16, 26, 60, 255))
|
|
sheet.paste(Image.alpha_composite(background, picture).convert("RGB"), (place * (width + 8), 0))
|
|
sheet.save(output / f"{name}.png")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
command = commands.add_parser("layout")
|
|
command.add_argument("--map", required=True)
|
|
commands.add_parser("build")
|
|
command = commands.add_parser("check")
|
|
command.add_argument("--map", required=True)
|
|
command.add_argument("--out", required=True)
|
|
arguments = parser.parse_args()
|
|
{"layout": layout, "build": build, "check": check}[arguments.command](arguments)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|