forked from Ports/goldeneye-pc-port
213 lines
8.4 KiB
Python
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())
|