139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
||
"""生成关卡左下角等距 X/Y 罗盘(灰箭头 + 青色标注,抗锯齿)。"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||
|
||
# HUD 显示约 1.55× Unity,2x 出图保证清晰
|
||
OUT_W, OUT_H = 802, 430
|
||
SS = 4
|
||
|
||
ARROW = (92, 98, 108, 255)
|
||
ARROW_EDGE = (58, 62, 70, 255)
|
||
TEAL = (64, 220, 188, 255)
|
||
LABEL_STROKE = (32, 36, 40, 230)
|
||
SHADOW = (0, 0, 0, 48)
|
||
|
||
|
||
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||
for fp in (
|
||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||
"/System/Library/Fonts/Helvetica.ttc",
|
||
"/Library/Fonts/Arial Bold.ttf",
|
||
):
|
||
try:
|
||
return ImageFont.truetype(fp, size)
|
||
except OSError:
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def _arrow(draw: ImageDraw.ImageDraw, ox: float, oy: float, angle: float, length: float) -> tuple[float, float]:
|
||
"""沿 angle(屏幕 Y 向上)画一支圆润箭头,返回箭头顶点。"""
|
||
dx, dy = math.cos(angle), -math.sin(angle)
|
||
px, py = -dy, dx
|
||
shaft = 12.5 * SS
|
||
head_len = 26 * SS
|
||
head_w = 20 * SS
|
||
tip_x = ox + dx * length
|
||
tip_y = oy + dy * length
|
||
base_x = ox + dx * (length - head_len)
|
||
base_y = oy + dy * (length - head_len)
|
||
|
||
# 箭身:圆头线段
|
||
draw.line([(ox, oy), (base_x, base_y)], fill=ARROW_EDGE, width=int(shaft + 2.4 * SS), joint="curve")
|
||
draw.line([(ox, oy), (base_x, base_y)], fill=ARROW, width=int(shaft), joint="curve")
|
||
|
||
hx, hy = px * head_w, py * head_w
|
||
head = [
|
||
(tip_x, tip_y),
|
||
(base_x + hx, base_y + hy),
|
||
(base_x - hx, base_y - hy),
|
||
]
|
||
draw.polygon(head, fill=ARROW_EDGE)
|
||
inset = 1.15 * SS
|
||
inner = [
|
||
(tip_x - dx * inset * 0.4, tip_y - dy * inset * 0.4),
|
||
(base_x + hx * 0.78 + dx * inset, base_y + hy * 0.78 + dy * inset),
|
||
(base_x - hx * 0.78 + dx * inset, base_y - hy * 0.78 + dy * inset),
|
||
]
|
||
draw.polygon(inner, fill=ARROW)
|
||
return tip_x, tip_y
|
||
|
||
|
||
def render() -> Image.Image:
|
||
w, h = OUT_W * SS, OUT_H * SS
|
||
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(layer)
|
||
|
||
ox = w * 0.50
|
||
oy = h * 0.84
|
||
length = min(w, h) * 0.46
|
||
ang_x = math.atan2(0.5, 1.0) # 2:1 等距 +X → 右上
|
||
ang_y = math.atan2(0.5, -1.0) # +Y → 左上
|
||
|
||
tx, ty = _arrow(draw, ox, oy, ang_x, length)
|
||
uy, vy = _arrow(draw, ox, oy, ang_y, length)
|
||
|
||
r = 11 * SS
|
||
draw.ellipse((ox - r - SS, oy - r - SS, ox + r + SS, oy + r + SS), fill=ARROW_EDGE)
|
||
draw.ellipse((ox - r, oy - r, ox + r, oy + r), fill=ARROW)
|
||
ir = 5.2 * SS
|
||
draw.ellipse((ox - ir, oy - ir, ox + ir, oy + ir), fill=TEAL)
|
||
|
||
font = _font(int(60 * SS))
|
||
labels = (("Y", uy, vy, ang_y), ("X", tx, ty, ang_x))
|
||
stroke = max(2, int(3.6 * SS))
|
||
for text, px, py, ang in labels:
|
||
dx, dy = math.cos(ang), -math.sin(ang)
|
||
lx = px + dx * 30 * SS
|
||
ly = py + dy * 30 * SS
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||
pos = (lx - tw / 2, ly - th / 2 - 6 * SS)
|
||
for oxs in range(-stroke, stroke + 1):
|
||
for oys in range(-stroke, stroke + 1):
|
||
if oxs * oxs + oys * oys > stroke * stroke:
|
||
continue
|
||
draw.text((pos[0] + oxs, pos[1] + oys), text, font=font, fill=LABEL_STROKE)
|
||
draw.text(pos, text, font=font, fill=TEAL)
|
||
|
||
alpha = layer.split()[3]
|
||
sh = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||
sh_draw = ImageDraw.Draw(sh)
|
||
# 用同一 alpha 做淡阴影
|
||
sh.putalpha(alpha.point(lambda a: min(255, int(a * 0.28)) if a else 0))
|
||
rgb = Image.new("RGB", (w, h), (0, 0, 0))
|
||
sh = Image.merge("RGBA", (*rgb.split(), sh.split()[-1]))
|
||
sh = sh.filter(ImageFilter.GaussianBlur(radius=1.6 * SS))
|
||
|
||
composed = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||
composed.alpha_composite(sh, (int(1.2 * SS), int(1.4 * SS)))
|
||
composed.alpha_composite(layer)
|
||
out = composed.resize((OUT_W, OUT_H), Image.Resampling.LANCZOS)
|
||
alpha = out.split()[3].point(lambda p: 255 if p >= 16 else 0)
|
||
box = alpha.getbbox()
|
||
if box:
|
||
pad = 16
|
||
x0 = max(0, box[0] - pad)
|
||
y0 = max(0, box[1] - pad)
|
||
x1 = min(out.width, box[2] + pad)
|
||
y1 = min(out.height, box[3] + pad)
|
||
out = out.crop((x0, y0, x1, y1))
|
||
return out
|
||
|
||
|
||
def write(path: Path) -> Path:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
render().save(path, format="PNG", optimize=True)
|
||
return path
|
||
|
||
|
||
if __name__ == "__main__":
|
||
out = Path(__file__).resolve().parents[1] / "assets/resources/textures/ui/ui-direction.png"
|
||
write(out)
|
||
print(f"wrote {out}")
|