HUD: - Hotbars fit a quarter of the screen, dropping to small then tiny slots (thinner outlines when tiny), with cooldown bars matching their size. - HUD elements are drawn on their own layer and composited at one opacity: hotbar slots are see-through until hovered, the rest fade while hovered. - Rival windows fit the right column in at most two rows, shrinking icons then font, then dropping icons for name, health and ping bars. - Tooltips number equipped items' slots and say Recoil for weapons that only push back. Previews use arrows, spread uses degrees. - Crosshair fills in and shakes with charge, and weapons shake after firing until their cooldown ends. DPS and speed meters off by default. Gameplay: - Dual Wield stacks: each copy fires another recently held weapon from a fanned off-hand, at its own level's cut. - Flamethrower: no slowing, smaller fireball with some recoil, and an airblast that knocks others away. - Iron Weights slam on any landing faster than a plain fall (platforms too), stacking radius and damage, and speeding fast falls. - Stuck arrows are one-way ledges until they fade, matching their (slightly larger) hit size. Attacks now collide with slopes. - Halved Tesla Coil, Recoil Coil, Diazepam, Shock Absorber, Feather and screenshake values. Art and tools: - Weapon and trinket icons merged into one sheet each (normal, small, tiny), cells in catalog order. Grimoire wisps stand upright. - Fonts gain the degree sign and four arrows, decoded from UTF-8. - Menu fighter preview walks at quarter speed. - Unequip from either catalog tab by right click or gamepad. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
11 KiB
Python
190 lines
11 KiB
Python
# Exports the game's fonts as atlases (resources/font/<name>.png) with a .fnt file each (JSON) that the game reads
|
|
# them by:
|
|
# tiny hand-drawn here, 3 pixels wide with a descender row, for squeezing text in
|
|
# small pixel_purl.ttf at its native 16 pixels per em, the game's usual text
|
|
# big small at twice the size, pixel for pixel, for text drawn twice as big
|
|
# huge small at three times the size, for text drawn three times as big
|
|
# Each glyph sits in its own cell, 16 to a row, drawn where the game puts it relative to the line's top-left, so the
|
|
# PNGs can be redrawn by hand afterwards (keeping to the cells, or changing the .fnt to match).
|
|
#
|
|
# A .fnt file holds the image, the scale it's drawn for (which size of text the game picks it for; tiny has none,
|
|
# being picked by name), the line height, the outline's thickness, and per character its cell in the image (x, y, w,
|
|
# h), where the cell goes relative to the pen at the line's top (dx, dy), and how far the pen then moves (advance).
|
|
#
|
|
# Existing files are left alone, so hand edits survive; --force writes them all again.
|
|
#
|
|
# python tools/fonts_export.py [--force]
|
|
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
FONTS = ROOT / "resources" / "font"
|
|
TTF = FONTS / "pixel_purl.ttf"
|
|
FIRST = 32
|
|
COUNT = 95
|
|
# Characters past ASCII, each after the last in its own cell (written into the .fnt as UTF-8).
|
|
EXTRA = "°←↑→↓"
|
|
# Small glyphs drawn here instead of taken from the TTF: the degree sign a pixel wider than its own, and arrows (the
|
|
# TTF has none), the up one turned for the rest.
|
|
ARROW_UP = ["..##..", ".####.", "######", "..##..", "..##..", "..##.."]
|
|
ARROW_RIGHT = ["".join(ARROW_UP[len(ARROW_UP) - 1 - y][x] for y in range(len(ARROW_UP))) for x in range(len(ARROW_UP[0]))]
|
|
SMALL_DRAWN = {
|
|
"°": {"rows": [".##.", "#..#", "#..#", "#..#", ".##."], "left": 0, "top": 2, "advance": 5},
|
|
"←": {"rows": [row[::-1] for row in ARROW_RIGHT], "left": 0, "top": 5, "advance": 7},
|
|
"↑": {"rows": ARROW_UP, "left": 0, "top": 5, "advance": 7},
|
|
"→": {"rows": ARROW_RIGHT, "left": 0, "top": 5, "advance": 7},
|
|
"↓": {"rows": ARROW_UP[::-1], "left": 0, "top": 5, "advance": 7},
|
|
}
|
|
COLUMNS = 16
|
|
EM_PIXELS = 16
|
|
LINE_HEIGHT = 12
|
|
TINY_LINE_HEIGHT = 8
|
|
TINY_CELL = 6
|
|
TINY_SPACE = 2
|
|
|
|
# The tiny font: rows 0-4 are the body and row 5 descenders.
|
|
TINY = {
|
|
' ': [], '!': ["#", "#", "#", ".", "#"], '"': ["#.#", "#.#"], '#': [".#.#.", "#####", ".#.#.", "#####", ".#.#."],
|
|
'$': [".##", "##.", ".#.", ".##", "##."], '%': ["#.#", "..#", ".#.", "#..", "#.#"],
|
|
'&': [".#..", "#.#.", ".#..", "#.##", ".##."], "'": ["#", "#"], '(': [".#", "#.", "#.", "#.", ".#"],
|
|
')': ["#.", ".#", ".#", ".#", "#."], '*': ["...", "#.#", ".#.", "#.#"], '+': ["...", ".#.", "###", ".#."],
|
|
',': ["..", "..", "..", "..", ".#", "#."], '-': ["...", "...", "###"], '.': [".", ".", ".", ".", "#"],
|
|
'/': ["..#", "..#", ".#.", "#..", "#.."],
|
|
'0': ["###", "#.#", "#.#", "#.#", "###"], '1': [".#.", "##.", ".#.", ".#.", "###"],
|
|
'2': ["##.", "..#", ".#.", "#..", "###"], '3': ["##.", "..#", ".#.", "..#", "##."],
|
|
'4': ["#.#", "#.#", "###", "..#", "..#"], '5': ["###", "#..", "##.", "..#", "##."],
|
|
'6': [".##", "#..", "###", "#.#", "###"], '7': ["###", "..#", ".#.", ".#.", ".#."],
|
|
'8': ["###", "#.#", "###", "#.#", "###"], '9': ["###", "#.#", "###", "..#", "##."],
|
|
':': [".", "#", ".", "#"], ';': ["..", ".#", "..", ".#", "#."], '<': ["..#", ".#.", "#..", ".#.", "..#"],
|
|
'=': ["...", "###", "...", "###"], '>': ["#..", ".#.", "..#", ".#.", "#.."], '?': ["##.", "..#", ".#.", "...", ".#."],
|
|
'@': [".##.", "#.##", "#.##", "#...", ".##."],
|
|
'A': [".#.", "#.#", "###", "#.#", "#.#"], 'B': ["##.", "#.#", "##.", "#.#", "##."],
|
|
'C': [".##", "#..", "#..", "#..", ".##"], 'D': ["##.", "#.#", "#.#", "#.#", "##."],
|
|
'E': ["###", "#..", "##.", "#..", "###"], 'F': ["###", "#..", "##.", "#..", "#.."],
|
|
'G': [".##", "#..", "#.#", "#.#", ".##"], 'H': ["#.#", "#.#", "###", "#.#", "#.#"],
|
|
'I': ["###", ".#.", ".#.", ".#.", "###"], 'J': ["..#", "..#", "..#", "#.#", ".#."],
|
|
'K': ["#.#", "#.#", "##.", "#.#", "#.#"], 'L': ["#..", "#..", "#..", "#..", "###"],
|
|
'M': ["#...#", "##.##", "#.#.#", "#...#", "#...#"], 'N': ["#..#", "##.#", "#.##", "#..#", "#..#"],
|
|
'O': [".#.", "#.#", "#.#", "#.#", ".#."], 'P': ["##.", "#.#", "##.", "#..", "#.."],
|
|
'Q': [".#.", "#.#", "#.#", "##.", ".##"], 'R': ["##.", "#.#", "##.", "#.#", "#.#"],
|
|
'S': [".##", "#..", ".#.", "..#", "##."], 'T': ["###", ".#.", ".#.", ".#.", ".#."],
|
|
'U': ["#.#", "#.#", "#.#", "#.#", "###"], 'V': ["#.#", "#.#", "#.#", "#.#", ".#."],
|
|
'W': ["#...#", "#...#", "#.#.#", "##.##", "#...#"], 'X': ["#.#", "#.#", ".#.", "#.#", "#.#"],
|
|
'Y': ["#.#", "#.#", ".#.", ".#.", ".#."], 'Z': ["###", "..#", ".#.", "#..", "###"],
|
|
'[': ["##", "#.", "#.", "#.", "##"], '\\': ["#..", "#..", ".#.", "..#", "..#"], ']': ["##", ".#", ".#", ".#", "##"],
|
|
'^': [".#.", "#.#"], '_': ["...", "...", "...", "...", "###"], '`': ["#.", ".#"],
|
|
'a': ["...", "##.", ".##", "#.#", ".##"], 'b': ["#..", "#..", "##.", "#.#", "##."],
|
|
'c': ["...", ".##", "#..", "#..", ".##"], 'd': ["..#", "..#", ".##", "#.#", ".##"],
|
|
'e': ["...", ".#.", "###", "#..", ".##"], 'f': [".##", ".#.", "###", ".#.", ".#."],
|
|
'g': ["...", ".##", "#.#", ".##", "..#", "##."], 'h': ["#..", "#..", "##.", "#.#", "#.#"],
|
|
'i': ["#", ".", "#", "#", "#"], 'j': [".#", "..", ".#", ".#", ".#", "#."], 'k': ["#..", "#.#", "##.", "##.", "#.#"],
|
|
'l': ["#.", "#.", "#.", "#.", ".#"], 'm': [".....", "##.#.", "#.#.#", "#.#.#", "#.#.#"],
|
|
'n': ["...", "##.", "#.#", "#.#", "#.#"], 'o': ["...", ".#.", "#.#", "#.#", ".#."],
|
|
'p': ["...", "##.", "#.#", "#.#", "##.", "#.."], 'q': ["...", ".##", "#.#", "#.#", ".##", "..#"],
|
|
'r': ["...", "#.#", "##.", "#..", "#.."], 's': ["...", ".##", "##.", "..#", "##."],
|
|
't': [".#.", "###", ".#.", ".#.", ".##"], 'u': ["...", "#.#", "#.#", "#.#", ".##"],
|
|
'v': ["...", "#.#", "#.#", "#.#", ".#."], 'w': [".....", "#...#", "#.#.#", "#.#.#", ".#.#."],
|
|
'x': ["...", "#.#", ".#.", ".#.", "#.#"], 'y': ["...", "#.#", "#.#", ".##", "..#", "##."],
|
|
'z': ["...", "###", ".#.", "#..", "###"], '{': [".##", ".#.", "##.", ".#.", ".##"], '|': ["#", "#", "#", "#", "#"],
|
|
'}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."], '°': [".##.", "#..#", ".##."],
|
|
'←': ["....", ".#..", "####", ".#.."], '↑': [".#.", "###", ".#.", ".#."], '→': ["....", "..#.", "####", "..#."],
|
|
'↓': ["...", ".#.", ".#.", "###", ".#."],
|
|
}
|
|
|
|
|
|
def characters():
|
|
return [chr(FIRST + i) for i in range(COUNT)] + list(EXTRA)
|
|
|
|
|
|
# Each glyph as rows of '#'/'.' with where its top-left sits relative to the pen at the line's top, and its advance.
|
|
def tiny_glyphs():
|
|
glyphs = {}
|
|
for character in characters():
|
|
rows = TINY[character]
|
|
width = max((len(row) for row in rows), default=0)
|
|
glyphs[character] = {"rows": rows, "left": 0, "top": 1, "advance": TINY_SPACE if character == " " else width + 1}
|
|
return glyphs
|
|
|
|
|
|
def small_glyphs():
|
|
font = ImageFont.truetype(str(TTF), EM_PIXELS)
|
|
glyphs = dict(SMALL_DRAWN)
|
|
for character in characters():
|
|
if character in glyphs:
|
|
continue
|
|
# Drawn with the line's top at the origin's y, as the game always has; the bounding box gives the pixels.
|
|
image = Image.new("L", (64, 64), 0)
|
|
draw = ImageDraw.Draw(image)
|
|
draw.fontmode = "1"
|
|
draw.text((16, 16), character, font=font, fill=255)
|
|
box = image.getbbox()
|
|
rows = [] if not box else ["".join("#" if image.getpixel((x, y)) else "." for x in range(box[0], box[2]))
|
|
for y in range(box[1], box[3])]
|
|
glyphs[character] = {"rows": rows, "left": box[0] - 16 if box else 0, "top": box[1] - 16 if box else 0,
|
|
"advance": round(font.getlength(character))}
|
|
return glyphs
|
|
|
|
|
|
# The glyphs at a whole multiple of their size, pixel for pixel.
|
|
def scaled(glyphs, factor):
|
|
return {character: {"rows": ["".join(c * factor for c in row) for row in glyph["rows"] for _ in range(factor)],
|
|
"left": glyph["left"] * factor, "top": glyph["top"] * factor,
|
|
"advance": glyph["advance"] * factor}
|
|
for character, glyph in glyphs.items()}
|
|
|
|
|
|
# Lays the glyphs out in equal cells (each as big as the furthest any glyph reaches, from the leftmost glyph's left and
|
|
# the line's top), a pixel apart, and writes the image and its .fnt.
|
|
def font_write(name, glyphs, scale, line_height, outline):
|
|
left = min(0, min(glyph["left"] for glyph in glyphs.values() if glyph["rows"]))
|
|
right = max(glyph["left"] + len(glyph["rows"][0]) for glyph in glyphs.values() if glyph["rows"])
|
|
bottom = max(glyph["top"] + len(glyph["rows"]) for glyph in glyphs.values() if glyph["rows"])
|
|
cell_w, cell_h = right - left, max(bottom, line_height)
|
|
rows = (len(glyphs) + COLUMNS - 1) // COLUMNS
|
|
image = Image.new("RGBA", (COLUMNS * (cell_w + 1), rows * (cell_h + 1)), (0, 0, 0, 0))
|
|
entries = {}
|
|
for i, character in enumerate(characters()):
|
|
glyph = glyphs[character]
|
|
x = i % COLUMNS * (cell_w + 1)
|
|
y = i // COLUMNS * (cell_h + 1)
|
|
for row_y, row in enumerate(glyph["rows"]):
|
|
for row_x, pixel in enumerate(row):
|
|
if pixel == "#":
|
|
image.putpixel((x + glyph["left"] - left + row_x, y + glyph["top"] + row_y), (255, 255, 255, 255))
|
|
entries[character] = {"x": x, "y": y, "w": cell_w, "h": cell_h, "dx": left, "dy": 0,
|
|
"advance": glyph["advance"]}
|
|
image.save(FONTS / f"{name}.png")
|
|
header = {"image": f"{name}.png", "scale": scale, "lineHeight": line_height, "outline": outline}
|
|
if scale is None:
|
|
del header["scale"]
|
|
lines = ["{"] + [f' {json.dumps(key)}: {json.dumps(value)},' for key, value in header.items()] + [' "glyphs": {']
|
|
lines += [f' {json.dumps(character, ensure_ascii=False)}: {json.dumps(entry)}{"," if i < len(entries) - 1 else ""}'
|
|
for i, (character, entry) in enumerate(entries.items())]
|
|
lines += [" }", "}"]
|
|
(FONTS / f"{name}.fnt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(f"{name}: {image.width}x{image.height}, cells {cell_w}x{cell_h}")
|
|
|
|
|
|
def main():
|
|
isForced = "--force" in sys.argv
|
|
small = small_glyphs()
|
|
fonts = {
|
|
"tiny": (tiny_glyphs(), None, TINY_LINE_HEIGHT, 1),
|
|
"small": (small, 1, LINE_HEIGHT, 1),
|
|
"big": (scaled(small, 2), 2, LINE_HEIGHT * 2, 2),
|
|
"huge": (scaled(small, 3), 3, LINE_HEIGHT * 3, 3),
|
|
}
|
|
for name, (glyphs, scale, line_height, outline) in fonts.items():
|
|
if not isForced and ((FONTS / f"{name}.png").exists() or (FONTS / f"{name}.fnt").exists()):
|
|
print(f"{name}: kept (--force to export again)")
|
|
continue
|
|
font_write(name, glyphs, scale, line_height, outline)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|