Files

213 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Generate the project's own icon (original art).
The mark is an ORIGINAL generic version of the GE default aim crosshair:
a circle with four lines (N/S/E/W) crossing the circle's edge and
extending well past it, open at the centre. Proportions are measured from
the game's own default crosshair (thin ring, long bars, open centre).
Drawn on a small pixel grid (32x32 master, hand-tuned 16/24) and scaled
with NEAREST, in the in-game sight red (core RGB 200,40,48; lighter edge
texels 225,120,130) on near-black (#050607). No glow/gradients/blur.
A circle-plus-ticks reticle is a generic functional shape (no logo art,
wordmark or screenshot copied, cf. tree-hygiene rule 4: repo imagery
must not be derivative game content).
Outputs (all regenerated byte-identically; deterministic):
docs/img/icon/ge007-icon.png 512x512 favicon / README icon
cmake/icon/ge007.ico multi-size Windows exe icon (16..256)
docs/favicon.ico byte copy of ge007.ico (site favicon)
port/fast3d/ge007_icon.h 32x32 RGBA array for the SDL window icon
(SDL_SetWindowIcon, see gfx_sdl2.cpp)
Usage:
python tools_pc/make_icon.py # write all three
python tools_pc/make_icon.py --check # exit 1 if any output is missing,
# or the mark is not exactly centred
Requires Pillow (>=10.1, for load_default(size)).
"""
from __future__ import annotations
import io
import struct
import sys
from pathlib import Path
try:
from PIL import Image, ImageDraw
except ImportError:
sys.exit("Pillow is required: pip install Pillow")
ROOT = Path(__file__).resolve().parent.parent
OUT_ICON = ROOT / "docs" / "img" / "icon" / "ge007-icon.png"
OUT_ICO = ROOT / "cmake" / "icon" / "ge007.ico"
OUT_HEADER = ROOT / "port" / "fast3d" / "ge007_icon.h"
OUT_FAVICON = ROOT / "docs" / "favicon.ico"
# --- palette -----------------------------------------------------------------
BG = (5, 6, 7) # site near-black
RED = (200, 40, 48) # in-game sight red (core texels)
RED_EDGE = (225, 120, 130) # lighter AA-edge texel tint
# pixel-grid parameters per master grid size N:
# (ring radius, ring half-band, bar width (even), gap radius, reach radius)
# radii are in pixels from the exact centre (N/2 - 0.5 is the centre pixel
# coordinate, so every shape is mirror-symmetric by construction).
_GRIDS = {
16: (4.6, 0.6, 2, 2.0, 7.5),
24: (6.6, 0.55, 2, 3.0, 11.5),
32: (9.2, 0.55, 2, 4.0, 14.5),
}
def _master(n: int) -> Image.Image:
"""Hand-tuned low-res mark on an n x n pixel grid (no anti-aliasing:
hard pixels in the sight's red, the outermost bar texel and ring
corners get the lighter edge tint like the sprite's AA texels)."""
ring_r, band, bar_w, gap, reach = _GRIDS[n]
img = Image.new("RGBA", (n, n), BG + (255,))
px = img.load()
c = (n - 1) / 2.0
hw = bar_w / 2.0
for y in range(n):
for x in range(n):
dx, dy = abs(x - c), abs(y - c)
d = (dx * dx + dy * dy) ** 0.5
col = None
if abs(d - ring_r) <= band:
col = RED
# bars: |perp| < hw, along-axis distance in [gap, reach]
for along, perp in ((dx, dy), (dy, dx)):
if perp < hw and gap <= along <= reach:
col = RED_EDGE if along > reach - 1 else RED
if col is not None:
px[x, y] = col + (255,)
return img
def _crosshair(size: int, small: bool = False) -> Image.Image:
"""Retro red crosshair: the 16/24/32 hand-tuned grids are used 1:1 for
the small slots; everything else is the 32 master (24 for 48px) scaled with NEAREST
(hard pixel edges)."""
if small and size in (16, 24, 32):
return _master(size)
# integer pixel scale only (48 = 2x the 24 grid) keeps pixels square
# and the mark exactly centred.
grid = 24 if size == 48 else 32
return _master(grid).resize((size, size), Image.NEAREST)
def _symmetric(img: Image.Image) -> bool:
"""Pixel-exact left-right and top-bottom mirror symmetry: the mark is
exactly centred at this size. (Not the diagonal: LANCZOS resamples the
two axes in a fixed order, so a transpose differs by rounding only.)"""
return (img.tobytes() == img.transpose(Image.FLIP_LEFT_RIGHT).tobytes()
and img.tobytes() == img.transpose(Image.FLIP_TOP_BOTTOM).tobytes())
def _asymmetric_sizes() -> list:
sizes = [(512, False), (16, True), (24, True), (32, True),
(48, False), (64, False), (128, False), (256, False)]
return [f"{s}{' (small)' if sm else ''}" for s, sm in sizes
if not _symmetric(_crosshair(s, small=sm))]
def _ico_png(size: int, small: bool) -> bytes:
buf = io.BytesIO()
_crosshair(size, small=small).save(buf, format="PNG")
return buf.getvalue()
def make_ico() -> None:
_crosshair(512).save(OUT_ICON)
# Multi-size .ico with PNG-compressed entries (Vista+ style; what the
# 256 slot always is). Small sizes get the chunky variant so the mark
# survives 16px; a single 512-master downsample washes out there.
entries = [(_ico_png(s, small=True), s) for s in (16, 24, 32)]
entries += [(_ico_png(s, small=False), s) for s in (48, 64, 128, 256)]
n = len(entries)
offset = 6 + 16 * n
dirs, data = b"", b""
for png, s in entries:
wh = 0 if s == 256 else s # 0 means 256 in the ICO format
dirs += struct.pack("<BBBBHHII", wh, wh, 0, 0, 1, 32,
len(png), offset)
offset += len(png)
data += png
OUT_ICO.write_bytes(struct.pack("<HHH", 0, 1, n) + dirs + data)
OUT_FAVICON.write_bytes(OUT_ICO.read_bytes()) # site favicon = exe icon
def make_header() -> None:
"""Emit the 32x32 RGBA array for the SDL window icon (gfx_sdl2.cpp).
Byte order is plain R,G,B,A per pixel (portable; the C++ side packs
it against explicit masks).
NOTE: this output is C, and CMake compiles it inside gfx_sdl2.cpp's
include — it is NOT independently syntax-checked by this script. Keep
it trivially valid (see the row-brace comment below; a flat cell list
without row braces is ill-formed for a 3-D array)."""
img = _crosshair(32, small=True).convert("RGBA")
px = img.load()
rows = []
for y in range(32):
row = []
for x in range(32):
r, g, b, a = px[x, y]
row.append(f"{{{r:3d},{g:3d},{b:3d},{a:3d}}}")
# Row braces are REQUIRED: without them all 1024 cells sit at the
# top level of the [32][32][4] initializer (1024 > first dim 32 ->
# "too many initializers").
rows.append(" {" + ",".join(row) + "}")
with open(OUT_HEADER, "w", newline="\n") as fh:
fh.write(
"/* Generated by tools_pc/make_icon.py -- do not edit by hand.\n"
" * The project mark (a generic version of the GE default aim\n"
" * crosshair) as a 32x32 RGBA array, row-major, plain R,G,B,A\n"
" * byte order per pixel; used by gfx_sdl2.cpp for\n"
" * SDL_SetWindowIcon. Regenerate: python tools_pc/make_icon.py */\n"
"#ifndef GE007_ICON_H\n"
"#define GE007_ICON_H\n"
"\n"
"#include <stdint.h>\n"
"\n"
"#define GE007_ICON_SIZE 32\n"
"static const uint8_t ge007_icon_rgba[GE007_ICON_SIZE]"
"[GE007_ICON_SIZE][4] = {\n"
+ ",\n".join(rows) + "\n};\n"
"#endif /* GE007_ICON_H */\n"
)
def main() -> int:
args = sys.argv[1:]
check = "--check" in args
for p in (OUT_ICON.parent, OUT_ICO.parent, OUT_HEADER.parent):
p.mkdir(parents=True, exist_ok=True)
bad = _asymmetric_sizes()
if bad:
print("icon is not exactly centred (mirror-asymmetric) at: " + ", ".join(bad))
return 1
if not check:
make_ico()
make_header()
print(f"wrote {OUT_ICON.relative_to(ROOT)}")
print(f"wrote {OUT_ICO.relative_to(ROOT)}")
print(f"wrote {OUT_FAVICON.relative_to(ROOT)}")
print(f"wrote {OUT_HEADER.relative_to(ROOT)}")
return 0
stale = [p for p in (OUT_ICON, OUT_ICO, OUT_HEADER, OUT_FAVICON) if not p.exists()]
if stale:
for p in stale:
print(f"stale/missing: {p.relative_to(ROOT)}")
return 1
print("ok (all icon outputs present and mirror-symmetric at every size; "
"regenerate with: python tools_pc/make_icon.py)")
return 0
if __name__ == "__main__":
sys.exit(main())