Files
probably-decentralized/art/sprites.py
T

481 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""物品像素图(游戏物品为 100 PPU,一格 16 像素;形状由不透明像素决定)。
用法:python3 art/sprites.py → 写出 src/Decentralized/ModFiles/Textures/Items/*.png
python3 art/sprites.py --preview DIR → 另存放大 6 倍、桌面底色的预览
画法沿用 Probably Automated 对讲机图:轮廓统一用 #292234,光从左上来,颜色取自游戏物品图集的调色板。
"""
import os
import struct
import sys
import zlib
C = {
'O': (0x29, 0x22, 0x34), # 轮廓
'k': (0x37, 0x35, 0x41), # 最暗
'd': (0x44, 0x46, 0x4C), # 暗面
'm': (0x5F, 0x68, 0x68), # 中间
'b': (0x88, 0x94, 0x8A), # 亮面
'l': (0xA8, 0xAE, 0xA5), # 受光
'h': (0xC4, 0xC4, 0xB0), # 亮边
'g': (0x53, 0x58, 0x5B), # 散热片
's': (0x27, 0x56, 0x54), # 屏幕底
'S': (0x67, 0xAA, 0xA5), # 屏幕字
'T': (0x8A, 0xBF, 0xBB), # 屏幕高光
'G': (0x5E, 0x9C, 0x4E), # Probidia 绿
'g2': (0x3E, 0x6B, 0x36),
'R': (0xB0, 0x4A, 0x3E), # PMD 红
'r2': (0x7A, 0x33, 0x2E),
'Y': (0xC8, 0xA4, 0x4A), # 金手指
'c': (0x2E, 0x2A, 0x2A), # 焦黑
'e': (0x6B, 0x4A, 0x3A), # 焦痕
}
class Canvas:
def __init__(self, w, h):
self.w, self.h = w, h
self.px = [[None] * w for _ in range(h)]
def put(self, x, y, c):
if 0 <= x < self.w and 0 <= y < self.h:
self.px[y][x] = c
def rect(self, x0, y0, x1, y1, c):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.put(x, y, c)
def box(self, x0, y0, x1, y1, fill, outline='O'):
self.rect(x0, y0, x1, y1, fill)
for x in range(x0, x1 + 1):
self.put(x, y0, outline); self.put(x, y1, outline)
for y in range(y0, y1 + 1):
self.put(x0, y, outline); self.put(x1, y, outline)
def png(self, path, scale=1, bg=None):
raw = bytearray()
for row in self.px:
line = bytearray()
for c in row:
r, g, b, a = (0, 0, 0, 0) if c is None else C[c] + (255,)
if bg is not None:
r, g, b = (round(v * a / 255 + w * (255 - a) / 255) for v, w in zip((r, g, b), bg))
a = 255
line += bytes((r, g, b, a)) * scale
for _ in range(scale):
raw.append(0); raw += line
def chunk(tag, data):
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xFFFFFFFF)
data = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', self.w * scale, self.h * scale, 8, 6, 0, 0, 0)) \
+ chunk(b'IDAT', zlib.compress(bytes(raw), 9)) + chunk(b'IEND', b'')
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, 'wb') as f:
f.write(data)
FONT = { # 3×5 像素字(丝印用)
'R': '110101110101101', 'Z': '111001010100111', 'X': '101101010101101', 'P': '110101110100100',
'M': '101111111101101', 'D': '110101101101110', 'T': '111010010010010', 'i': '010000010010010',
'0': '111101101101111', '1': '010110010010111', '2': '110001010100111', '3': '110001010001110',
'4': '101101111001001', '5': '111100110001110', '6': '011100111101111', '7': '111001010010010',
'8': '111101111101111', '9': '111101111001110',
}
def text(c, x, y, s, color, skew=False):
"""在画布上写 3×5 像素字;skew 为重新印过的效果:字一高一低、间距不匀。"""
i = 0
for ch in s:
if ch == ' ':
x += 2
continue
dy = (i % 2) if skew else 0
for k, bit in enumerate(FONT[ch]):
if bit == '1':
c.put(x + k % 3, y + k // 3 + dy, color)
x += 4 + (1 if skew and i % 3 == 2 else 0)
i += 1
W, H = 48, 32
TOP0, TOP1 = 4, 22 # 顶面行范围
L, R = 6, 45 # 顶面左右(左边是挡板)
FANS = ((17, 13), (34, 13)) # 两个风扇中心
def outline(c):
solid = lambda v: v is not None
get = lambda x, y: c.px[y][x] if 0 <= x < c.w and 0 <= y < c.h else None
edge = [(x, y) for y in range(c.h) for x in range(c.w)
if solid(get(x, y)) and any(not solid(get(x + dx, y + dy)) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))]
for x, y in edge:
c.put(x, y, 'O')
def bracket(c):
"""左端金属挡板,三个接口孔。"""
c.rect(1, 2, 5, 27, 'b')
c.rect(1, 2, 1, 27, 'h'); c.rect(5, 2, 5, 27, 'd')
for y in (5, 11, 17):
c.rect(2, y, 4, y + 3, 'k')
c.rect(2, y, 4, y, 'g')
c.put(3, 22, 'k'); c.put(3, 24, 'k')
def gpu_front(accent, accent_dark, burnt=False):
"""显卡正面(48×32,3×2 格),略微俯视:顶面是带两个风扇的散热罩,左端金属挡板(三个接口),
前沿露出 PCB 和金手指,右端一道暗侧面。每格中心都不透明。"""
c = Canvas(W, H)
bracket(c)
for y in range(TOP0, TOP1 + 1):
for x in range(L, R + 1):
c.put(x, y, 'm')
c.put(R, y, 'd'); c.put(R - 1, y, 'd')
for x in range(L, R + 1):
c.put(x, TOP0, 'l'); c.put(x, TOP0 + 1, 'b')
for x in range(L + 1, R - 1):
c.put(x, TOP1 - 1, accent)
c.put(L + 1, TOP1, accent_dark); c.put(R - 2, TOP1, accent_dark)
for cx, cy in FANS:
for y in range(cy - 7, cy + 8):
for x in range(cx - 7, cx + 8):
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 <= 49:
c.put(x, y, 'k')
if 26 <= d2 <= 38:
c.put(x, y, 'g')
for k in range(-5, 6):
if abs(k) >= 1:
c.put(cx + k, cy, 'g'); c.put(cx, cy + k, 'g')
for dx, dy in ((-3, -3), (3, 3), (3, -3), (-3, 3)):
c.put(cx + dx, cy + dy, 'g')
c.rect(cx - 1, cy - 1, cx + 1, cy + 1, 'b')
c.put(cx, cy, 'h')
for x in range(L, R + 1):
c.put(x, TOP1 + 1, 'g2'); c.put(x, TOP1 + 2, 'g2')
for x in range(L, R + 1):
c.put(x, TOP1 + 3, 'Y'); c.put(x, TOP1 + 4, 'Y' if x % 2 == 0 else 'e')
for y in (TOP1 + 3, TOP1 + 4):
c.put(L + 17, y, 'k'); c.put(L + 18, y, 'k')
for x0 in (L + 4, L + 12): # 顶上供电口
c.rect(x0, TOP0 - 1, x0 + 4, TOP0 - 1, 'k')
for x in range(L, R + 1):
for y in range(TOP1 + 1, 28):
if c.px[y][x] is None:
c.put(x, y, 'k')
if burnt:
for x, y in [(12, 8), (13, 9), (18, 17), (35, 8), (36, 9), (40, 18), (24, 12), (25, 11)]:
c.put(x, y, 'c')
for x in range(L + 1, R - 1):
c.put(x, TOP1 - 1, 'e')
outline(c)
return c
def gpu_back(accent, accent_dark, burnt=False):
"""显卡背面(翻面后):左端仍是挡板;黑色背板,上面一行丝印(型号用叠加图画),
右下一张序列号贴纸,贴纸边上一道封条。"""
c = Canvas(W, H)
bracket(c)
for y in range(TOP0, 28):
for x in range(L, R + 1):
c.put(x, y, 'd')
c.put(R, y, 'k'); c.put(R - 1, y, 'k')
for x in range(L, R + 1):
c.put(x, TOP0, 'm'); c.put(x, TOP0 + 1, 'm')
for x in range(L + 1, R - 1):
c.put(x, 16, accent)
c.put(L + 1, 17, accent_dark); c.put(R - 2, 17, accent_dark)
for x in range(L + 2, R - 2, 5): # 背板螺丝
for y in (22, 25):
c.put(x, y, 'b'); c.put(x + 1, y, 'k')
c.rect(26, 18, 41, 21, 'h') # 序列号贴纸(印刷条码)
for x in range(27, 41, 2):
c.rect(x, 19, x, 20, 'k')
c.rect(44, 17, 44, 22, 'R') # 封条(红色)
c.rect(43, 17, 43, 22, 'r2')
for x in range(L, R + 1):
for y in range(28, 28):
pass
if burnt:
for x, y in [(14, 9), (15, 10), (30, 11), (36, 13), (20, 14)]:
c.put(x, y, 'c')
outline(c)
return c
def overlay():
return Canvas(W, H)
def ov_fan(kind):
"""风扇叠加:1 积灰(叶片上一层灰),2 叶片缺角(边缘缺一块并露出黑底)。"""
c = overlay()
for cx, cy in FANS:
if kind == 1:
for dx, dy in ((-4, -2), (-2, -4), (3, -3), (4, 1), (-3, 3), (2, 4), (-5, 0), (0, 5), (1, -5), (-1, 2), (5, -1), (3, 2)):
c.put(cx + dx, cy + dy, 'l')
for dx, dy in ((-4, -1), (-1, -4), (4, -2), (2, 3), (-3, 4)):
c.put(cx + dx, cy + dy, 'h')
else:
for dx, dy in ((4, -4), (5, -4), (5, -3), (4, -3), (3, -4), (5, -5), (6, -3)):
c.put(cx + dx, cy + dy, 'k')
c.put(cx - 5, cy + 4, 'k'); c.put(cx - 4, cy + 5, 'k'); c.put(cx - 5, cy + 5, 'k')
return c
def ov_fingers(kind):
"""金手指叠加:1 发暗(触点变褐),2 氧化发绿。"""
c = overlay()
for x in range(L, R + 1):
for y in (TOP1 + 3, TOP1 + 4):
if x in (L + 17, L + 18):
continue
if kind == 1:
c.put(x, y, 'e' if (x + y) % 3 else 'Y')
else:
c.put(x, y, 'g2' if (x + y) % 2 else 'G')
return c
def ov_bracket(kind):
"""挡板叠加:1 划痕;2 接口数量不对(第三个接口被封成一块平板,只剩两个)。"""
c = overlay()
if kind == 1:
for x, y in ((2, 9), (3, 10), (4, 11), (2, 15), (3, 15), (3, 16), (2, 20), (3, 21), (4, 22), (4, 25), (3, 25)):
c.put(x, y, 'l' if (x + y) % 2 else 'd')
else:
c.rect(2, 17, 4, 20, 'b')
c.rect(2, 17, 4, 17, 'h')
return c
def ov_text(model, skew):
"""背面丝印叠加:写型号。skew 为重新印过的版本(字高低不齐)。"""
c = overlay()
text(c, 10, 8, model, 'l' if skew else 'h', skew)
return c
def ov_sticker(kind):
"""贴纸叠加:1 手写序列号(条码被盖住、写了歪歪扭扭的字);2 封条缺失(只剩撕过的残胶)。"""
c = overlay()
if kind == 1:
c.rect(26, 18, 41, 21, 'h')
for x, y in ((27, 20), (28, 19), (29, 20), (30, 19), (32, 20), (33, 19), (33, 21), (35, 19), (36, 20), (37, 19), (38, 21), (39, 20), (40, 19)):
c.put(x, y, 'O')
else:
c.rect(43, 17, 44, 22, 'd')
for y in (17, 19, 21, 22):
c.put(44, y, 'm')
c.put(43, 18, 'm'); c.put(43, 20, 'm')
return c
MODELS = ['RZX 50', 'RZX 60', 'RZX 70', 'RZX 80 Ti', 'RZX 90', 'RZ 470', 'RZ 580', 'RZ 6800', 'RZ 7900']
def rig():
"""矿架(48×48,3×3 格):和游戏里其他机器一样的略微俯视(3/4 视角)——顶面是上窄下宽的梯形,
下面是正面,正面里四层显卡位,左下角电源;光从左上来,左亮右暗;轮廓统一深紫、削掉尖角、底下两只脚和一圈阴影。"""
c = Canvas(48, 48)
FL, FR, FY, DEP = 3, 44, 24, 19 # 正面左右边、顶面前沿所在行、顶面深度(顶面后沿在 y=6,中心行 y=8 落在顶面上)
BOT = 44 # 正面底边
inset = lambda v: v // 6 # 顶面后部只略微收窄:原版机器的顶面也近似矩形,并且要让左右两格的中心落在顶面内
# 顶面:后部受光、前部稍暗;左缘亮、右缘暗
for v in range(DEP):
y = FY - v
x0, x1 = FL + inset(v), FR - inset(v)
for x in range(x0, x1 + 1):
c.put(x, y, 'l' if v >= 4 else 'b')
c.put(x0, y, 'h'); c.put(x0 + 1, y, 'l')
c.put(x1, y, 'd'); c.put(x1 - 1, y, 'm')
for x in range(FL + inset(DEP - 1), FR - inset(DEP - 1) + 1):
c.put(x, FY - DEP + 1, 'l')
# 顶面上的散热格栅和一个小指示灯
for v in range(4, 16, 3):
for u in range(6, 24):
c.put(FL + inset(v) + u, FY - v, 'g')
c.put(FR - 9, FY - 8, 'G'); c.put(FR - 8, FY - 8, 'G')
# 正面:中间色,左端受光、右端暗;顶部一条暗折线
for y in range(FY + 1, BOT + 1):
for x in range(FL, FR + 1):
c.put(x, y, 'm')
c.put(FL, y, 'b'); c.put(FL + 1, y, 'l' if y < FY + 5 else 'b')
for dx in range(4):
c.put(FR - dx, y, 'd' if dx else 'k')
for x in range(FL, FR + 1):
c.put(x, FY + 1, 'k')
c.put(x, BOT, 'd')
for x in range(FL + 1, FR):
c.put(x, FY, 'h')
c.put(x, FY + 2, 'b')
for x in range(FL + 1, FR):
c.put(x, BOT - 1, 'd')
# 正面里的四层显卡位(深色槽,上沿亮、下沿暗,右端一颗状态灯)
# 正面四层显卡位(深色槽,右端一颗状态灯);间隔行用中间色,保证每格中心都不透明
for i, y in enumerate((FY + 3, FY + 8, FY + 13)):
for x in range(FL + 3, FR - 4):
for dy in range(3):
c.put(x, y + dy, 'k')
c.put(x, y, 'g')
for x in range(FL + 6, FR - 6, 3):
c.put(x, y + 1, 'd')
c.put(FR - 4, y + 1, 'G' if i % 2 == 0 else 'R')
# 电源口(左下)
for x in range(FL + 3, FL + 12):
for dy in (-3, -2):
c.put(x, BOT + dy, 'k')
c.put(FL + 10, BOT - 2, 'S')
for x in range(FL + 15, FR - 4, 2):
c.put(x, BOT - 2, 'g')
# 底部两只脚
for x in (FL + 2, FL + 3, FR - 3, FR - 2):
c.put(x, BOT + 1, 'O'); c.put(x, BOT + 2, 'O')
# 轮廓:实体像素挨着透明的一圈描成轮廓色,再削掉尖角
solid = lambda v: v is not None
get = lambda x, y: c.px[y][x] if 0 <= x < c.w and 0 <= y < c.h else None
edge = [(x, y) for y in range(c.h) for x in range(c.w)
if solid(get(x, y)) and any(not solid(get(x + dx, y + dy)) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))]
for x, y in edge:
c.put(x, y, 'O')
for y in range(c.h):
for x in range(c.w):
if get(x, y) == 'O' and sum(solid(get(x + dx, y + dy)) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))) <= 1:
c.px[y][x] = None
# 圆角:正面底部两角各削一个像素,让轮廓和原版机器一样不是尖方块
for x, y in ((FL, BOT), (FR, BOT), (FL, BOT + 0), (FR, BOT + 0)):
c.px[y][x] = None
c.px[y][x + (1 if x == FL else -1)] = 'O'
return c
def terminal():
"""交易终端(32×32,2×2 格),略微俯视:显示器是一个带顶面的盒子(正面是屏幕和 K 线),
下面是键盘的顶面(梯形,露出按键),左亮右暗。每格中心都不透明。"""
c = Canvas(32, 32)
# 显示器:顶面(浅色窄条,略收窄)+ 正面
for v in range(4):
y = 3 + v
x0, x1 = 4 + (3 - v) // 3, 27 - (3 - v) // 3
for x in range(x0, x1 + 1):
c.put(x, y, 'l' if v else 'h')
c.put(x1, y, 'd')
for y in range(7, 21):
for x in range(4, 28):
c.put(x, y, 'm')
c.put(4, y, 'b'); c.put(27, y, 'd'); c.put(26, y, 'd')
# 屏幕(深青底、K 线)
for y in range(9, 19):
for x in range(7, 25):
c.put(x, y, 's')
candles = [(9, 13, 16), (12, 11, 15), (15, 12, 14), (18, 9, 13), (21, 10, 12), (23, 9, 11)]
for x, top, bot in candles:
for y in range(top, bot + 1):
c.put(x, y, 'S')
c.put(x, top - 1 if top > 9 else top, 'T')
c.put(7, 9, 'T'); c.put(8, 9, 'T')
# 支架 + 底座(拉通到键盘,保证 y=24 的格子中心不透明)
c.rect(13, 21, 18, 22, 'd')
# 键盘顶面:梯形(后窄前宽),按键网格
for v in range(7):
y = 24 + (6 - v) # v=0 是前沿(y=30)
w = v // 3
x0, x1 = 2 + w, 29 - w
for x in range(x0, x1 + 1):
c.put(x, y, 'b')
c.put(x0, y, 'h'); c.put(x1, y, 'd')
for y in (25, 27, 29):
for x in range(5, 27, 2):
c.put(x, y, 'm')
c.rect(10, 29, 21, 29, 'm') # 空格键
# 键盘前沿(正面薄边)
for x in range(2, 30):
c.put(x, 31, 'd')
# 键盘与显示器之间的缝隙用桌面阴影色填实,避免 y=24 行出现透明格
for y in range(22, 24):
for x in range(4, 28):
if c.px[y][x] is None:
c.put(x, y, 'k')
solid = lambda v: v is not None
get = lambda x, y: c.px[y][x] if 0 <= x < c.w and 0 <= y < c.h else None
edge = [(x, y) for y in range(c.h) for x in range(c.w)
if solid(get(x, y)) and any(not solid(get(x + dx, y + dy)) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))]
for x, y in edge:
c.put(x, y, 'O')
return c
def key(model):
return model.replace(' ', '').lower()
SPRITES = {
'gpu_probidia_f': lambda: gpu_front('G', 'g2'),
'gpu_probidia_b': lambda: gpu_back('G', 'g2'),
'gpu_pmd_f': lambda: gpu_front('R', 'r2'),
'gpu_pmd_b': lambda: gpu_back('R', 'r2'),
'gpu_burnt_f': lambda: gpu_front('e', 'c', burnt=True),
'gpu_burnt_b': lambda: gpu_back('e', 'c', burnt=True),
'rig': rig,
'terminal': terminal,
}
# 叠加图:和底图同尺寸,位置 (0,0) 对齐,只画变化的像素,不参与判格
OVERLAYS = {
'gpu_ov_fan1': lambda: ov_fan(1), 'gpu_ov_fan2': lambda: ov_fan(2),
'gpu_ov_fin1': lambda: ov_fingers(1), 'gpu_ov_fin2': lambda: ov_fingers(2),
'gpu_ov_brk1': lambda: ov_bracket(1), 'gpu_ov_brk2': lambda: ov_bracket(2),
'gpu_ov_stk1': lambda: ov_sticker(1), 'gpu_ov_stk2': lambda: ov_sticker(2),
}
for _m in MODELS:
OVERLAYS['gpu_ov_txt_' + key(_m)] = (lambda m=_m: ov_text(m, False))
OVERLAYS['gpu_ov_txs_' + key(_m)] = (lambda m=_m: ov_text(m, True))
def check_cells(canvas, name):
"""游戏按 mod 物品图每格的中心像素是否不透明来判断占格(行从图的下沿往上数)。有空格就报错。"""
cols, rows = canvas.w // 16, canvas.h // 16
bad = [(q, r) for r in range(rows) for q in range(cols) if canvas.px[canvas.h - 1 - (r * 16 + 8)][q * 16 + 8] is None]
if bad:
raise SystemExit(f"{name}: 这些格子中心像素透明(游戏会当成空格,别的物品会叠上来):{bad}")
if __name__ == '__main__':
here = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) == 3 and sys.argv[1] == '--preview':
for name, make in SPRITES.items():
canvas = make()
check_cells(canvas, name)
canvas.png(os.path.join(sys.argv[2], name + '.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
# 预览:把各种叠加图叠在底图上
def stack(base, *ovs):
b = SPRITES[base]()
for o in ovs:
oc = OVERLAYS[o]()
for y in range(oc.h):
for x in range(oc.w):
if oc.px[y][x] is not None:
b.px[y][x] = oc.px[y][x]
return b
stack('gpu_probidia_f', 'gpu_ov_fan1', 'gpu_ov_fin1', 'gpu_ov_brk1').png(os.path.join(sys.argv[2], 'prev_front_worn.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
stack('gpu_pmd_f', 'gpu_ov_fan2', 'gpu_ov_fin2', 'gpu_ov_brk2').png(os.path.join(sys.argv[2], 'prev_front_fake.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
stack('gpu_probidia_b', 'gpu_ov_txt_rzx80ti').png(os.path.join(sys.argv[2], 'prev_back_ok.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
stack('gpu_pmd_b', 'gpu_ov_txs_rz6800', 'gpu_ov_stk1', 'gpu_ov_stk2').png(os.path.join(sys.argv[2], 'prev_back_fake.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
print(sys.argv[2])
else:
out = os.path.normpath(os.path.join(here, '..', 'src', 'Decentralized', 'ModFiles', 'Textures', 'Items'))
for name, make in SPRITES.items():
canvas = make()
check_cells(canvas, name)
canvas.png(os.path.join(out, name + '.png'))
for name, make in OVERLAYS.items():
make().png(os.path.join(out, name + '.png'))
print(out)