#!/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) def gpu(accent, accent_dark, burnt=False): """显卡(32×32,2×2 格),略微俯视:顶面是带两个风扇的散热罩,左端金属挡板(带接口), 前沿露出 PCB 和金手指,右端一道暗侧面,顶上一排供电口。每格中心都不透明。""" c = Canvas(32, 32) TOP0, TOP1 = 4, 22 # 顶面行范围 L, R = 6, 29 # 顶面左右(左边是挡板) # 金属 I/O 挡板(左端竖条,带两个接口孔) c.rect(1, 2, 5, 27, 'b') c.rect(1, 2, 1, 27, 'h'); c.rect(5, 2, 5, 27, 'd') for y in (6, 12): # 两个视频接口(深色矩形孔,带高光边) c.rect(2, y, 4, y + 3, 'k') c.rect(2, y, 4, y, 'g') c.put(3, 20, 'k'); c.put(3, 22, 'k'); c.put(3, 24, 'k') # 下部散热孔 # 散热罩顶面 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 in (11, 22): cy = 13 for y in range(cy - 6, cy + 7): for x in range(cx - 6, cx + 7): d2 = (x - cx) ** 2 + (y - cy) ** 2 if d2 <= 36: c.put(x, y, 'k') if 20 <= d2 <= 30: c.put(x, y, 'g') for k in range(-4, 5): if abs(k) >= 1: c.put(cx + k, cy, 'g'); c.put(cx, cy + k, 'g') c.put(cx - 2, cy - 2, 'g'); c.put(cx + 2, cy + 2, 'g'); c.put(cx + 2, cy - 2, 'g'); c.put(cx - 2, cy + 2, 'g') c.rect(cx - 1, cy - 1, cx + 1, cy + 1, 'b') c.put(cx, cy, 'h') # 前沿:PCB 绿色薄边 + 金手指 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): # 金手指:连续金色带,中间一道缺口(PCIe 键位),带深色触点线 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 + 13, y, 'k'); c.put(L + 14, y, 'k') # 顶上一排供电口(两个黑色插口) for x0 in (L + 3, L + 9): c.rect(x0, TOP0 - 1, x0 + 4, TOP0 - 1, 'k') # 保证每格中心不透明:下半部(第二行格子,y 16..31 的中心 y=24)落在金手指/PCB 上 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 [(9, 8), (10, 9), (13, 17), (24, 8), (25, 9), (27, 18), (17, 12), (18, 11)]: c.put(x, y, 'c') for x in range(L + 1, R - 1): c.put(x, TOP1 - 1, 'e') for x, y in [(15, 5), (16, 5), (20, 21), (26, 14)]: c.put(x, y, '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') return c 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 SPRITES = { 'gpu_probidia': lambda: gpu('G', 'g2'), 'gpu_pmd': lambda: gpu('R', 'r2'), 'gpu_burnt': lambda: gpu('e', 'c', burnt=True), 'rig': rig, 'terminal': terminal, } 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)) 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')) print(out)