HUD pass, weapon and trinket rework, and font arrows
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>
This commit is contained in:
1 parent
6003175173
commit
6f112ab231
54 files changed
+1150
-3046
No files matched your search
+23
-6
@@ -26,6 +26,19 @@ 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
|
||||
@@ -77,12 +90,14 @@ TINY = {
|
||||
'v': ["...", "#.#", "#.#", "#.#", ".#."], 'w': [".....", "#...#", "#.#.#", "#.#.#", ".#.#."],
|
||||
'x': ["...", "#.#", ".#.", ".#.", "#.#"], 'y': ["...", "#.#", "#.#", ".##", "..#", "##."],
|
||||
'z': ["...", "###", ".#.", "#..", "###"], '{': [".##", ".#.", "##.", ".#.", ".##"], '|': ["#", "#", "#", "#", "#"],
|
||||
'}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."],
|
||||
'}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."], '°': [".##.", "#..#", ".##."],
|
||||
'←': ["....", ".#..", "####", ".#.."], '↑': [".#.", "###", ".#.", ".#."], '→': ["....", "..#.", "####", "..#."],
|
||||
'↓': ["...", ".#.", ".#.", "###", ".#."],
|
||||
}
|
||||
|
||||
|
||||
def characters():
|
||||
return [chr(FIRST + i) for i in range(COUNT)]
|
||||
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.
|
||||
@@ -97,8 +112,10 @@ def tiny_glyphs():
|
||||
|
||||
def small_glyphs():
|
||||
font = ImageFont.truetype(str(TTF), EM_PIXELS)
|
||||
glyphs = {}
|
||||
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)
|
||||
@@ -127,7 +144,7 @@ def font_write(name, glyphs, scale, line_height, outline):
|
||||
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 = (COUNT + COLUMNS - 1) // COLUMNS
|
||||
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()):
|
||||
@@ -145,10 +162,10 @@ def font_write(name, glyphs, scale, line_height, 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)}: {json.dumps(entry)}{"," if i < COUNT - 1 else ""}'
|
||||
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")
|
||||
(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}")
|
||||
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# Makes the small (16x16) and tiny (8x8) icon sheets from the full 24x24 weapon and trinket icon sheets, with an anm2
|
||||
# for each listing the same regions and animations at the new size.
|
||||
#
|
||||
# The icons are a black body with grey detail inside a one pixel white outline, all tinted when drawn. Shrinking that
|
||||
# outline along with the rest would smear it, so only the body is shrunk (a cell is body where enough of what it covers
|
||||
# was, grey where most of that was grey) and a fresh one pixel outline is drawn around it at the new size. Tiny icons
|
||||
# have no room for that, so they're the shrunk silhouette in white, keeping black only where it's thick enough.
|
||||
#
|
||||
# python tools/icons_resize.py
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
GFX = ROOT / "resources" / "gfx"
|
||||
SHEETS = ["weapon_icons", "trinkets"]
|
||||
SIZES = {"small": 16, "tiny": 8}
|
||||
CELL = 24
|
||||
BODY_COVERAGE = 0.4
|
||||
SHAPE_COVERAGE = 0.5
|
||||
INNER_COVERAGE = 0.6
|
||||
WHITE = (255, 255, 255, 255)
|
||||
GREY = (128, 128, 128, 255)
|
||||
BLACK = (0, 0, 0, 255)
|
||||
|
||||
|
||||
def is_body(pixel):
|
||||
return pixel[3] > 0 and pixel[:3] != (255, 255, 255)
|
||||
|
||||
|
||||
def coverage_get(source, x, y, scale):
|
||||
"""How much of a cell at the new size was opaque, body, and grey body, as shares of the cell."""
|
||||
opaque = covered = grey = total = 0.0
|
||||
for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))):
|
||||
for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))):
|
||||
# How much of the source pixel falls inside this cell.
|
||||
weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \
|
||||
(min(sy + 1, (y + 1) * scale) - max(sy, y * scale))
|
||||
total += weight
|
||||
pixel = source[sx, sy]
|
||||
opaque += weight * (pixel[3] > 0)
|
||||
if is_body(pixel):
|
||||
covered += weight
|
||||
grey += weight * (pixel[:3] == (128, 128, 128))
|
||||
return opaque / total, covered / total, grey / total
|
||||
|
||||
|
||||
def icon_tiny(icon, size):
|
||||
"""Too small for an outline around a body: the whole silhouette in white, with black (or grey) left only where it's
|
||||
thick enough to keep a white rim all round."""
|
||||
source = icon.load()
|
||||
scale = CELL / size
|
||||
cells = [[coverage_get(source, x, y, scale) for x in range(size)] for y in range(size)]
|
||||
shape = [[cells[y][x][0] >= SHAPE_COVERAGE for x in range(size)] for y in range(size)]
|
||||
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
pixels = result.load()
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
if not shape[y][x]:
|
||||
continue
|
||||
opaque, covered, grey = cells[y][x]
|
||||
inner = all(0 <= x + dx < size and 0 <= y + dy < size and shape[y + dy][x + dx]
|
||||
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
|
||||
if inner and covered >= INNER_COVERAGE:
|
||||
pixels[x, y] = GREY if grey > covered / 2 else BLACK
|
||||
else:
|
||||
pixels[x, y] = WHITE
|
||||
return result
|
||||
|
||||
|
||||
def icon_shrink(icon, size):
|
||||
source = icon.load()
|
||||
scale = CELL / size
|
||||
body = [[None] * size for _ in range(size)]
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
covered = grey = total = 0.0
|
||||
for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))):
|
||||
for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))):
|
||||
# How much of the source pixel falls inside this cell.
|
||||
weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \
|
||||
(min(sy + 1, (y + 1) * scale) - max(sy, y * scale))
|
||||
total += weight
|
||||
pixel = source[sx, sy]
|
||||
if is_body(pixel):
|
||||
covered += weight
|
||||
grey += weight * (pixel[:3] == (128, 128, 128))
|
||||
if covered / total >= BODY_COVERAGE:
|
||||
body[y][x] = GREY if grey > covered / 2 else BLACK
|
||||
|
||||
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
pixels = result.load()
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
if body[y][x]:
|
||||
pixels[x, y] = body[y][x]
|
||||
continue
|
||||
touching = any(0 <= x + dx < size and 0 <= y + dy < size and body[y + dy][x + dx]
|
||||
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
|
||||
if touching:
|
||||
pixels[x, y] = WHITE
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
for sheet in SHEETS:
|
||||
art = json.loads((GFX / f"{sheet}.anm2").read_text())
|
||||
image = Image.open(GFX / f"{sheet}.png").convert("RGBA")
|
||||
for suffix, size in SIZES.items():
|
||||
columns, rows = image.width // CELL, image.height // CELL
|
||||
result = Image.new("RGBA", (columns * size, rows * size), (0, 0, 0, 0))
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
icon = image.crop((column * CELL, row * CELL, (column + 1) * CELL, (row + 1) * CELL))
|
||||
shrink = icon_tiny if suffix == "tiny" else icon_shrink
|
||||
result.paste(shrink(icon, size), (column * size, row * size))
|
||||
name = f"{sheet}_{suffix}"
|
||||
result.save(GFX / f"{name}.png")
|
||||
|
||||
shrunk = json.loads(json.dumps(art))
|
||||
spritesheet = shrunk["spritesheets"][0]
|
||||
spritesheet["path"] = f"{name}.png"
|
||||
for region in spritesheet["regions"]:
|
||||
region["crop"] = [region["crop"][0] // CELL * size, region["crop"][1] // CELL * size]
|
||||
region["size"] = [size, size]
|
||||
(GFX / f"{name}.anm2").write_text(json.dumps(shrunk, indent=2) + "\n")
|
||||
print(f"{name}: {result.width}x{result.height}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user