2026-11-03 09:11:46 +08:00
|
|
|
|
#!/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×16,2×1 格):外壳、两个风扇、品牌色条、金手指。"""
|
|
|
|
|
|
c = Canvas(32, 16)
|
|
|
|
|
|
c.box(1, 2, 30, 12, 'm')
|
|
|
|
|
|
c.rect(2, 3, 29, 3, 'l') # 顶边受光
|
|
|
|
|
|
c.rect(2, 11, 29, 11, 'd') # 底边暗面
|
|
|
|
|
|
for cx in (9, 21): # 风扇
|
|
|
|
|
|
for y in range(5, 11):
|
|
|
|
|
|
for x in range(cx - 4, cx + 5):
|
|
|
|
|
|
dx, dy = x - cx, y - 7.5
|
|
|
|
|
|
if dx * dx + dy * dy <= 16:
|
|
|
|
|
|
c.put(x, y, 'k')
|
|
|
|
|
|
if 6 <= dx * dx + dy * dy <= 16:
|
|
|
|
|
|
c.put(x, y, 'g')
|
|
|
|
|
|
c.put(cx, 7, 'b'); c.put(cx, 8, 'b')
|
|
|
|
|
|
c.rect(2, 4, 29, 4, accent) # 品牌色条
|
|
|
|
|
|
c.put(2, 5, accent_dark); c.put(29, 5, accent_dark)
|
|
|
|
|
|
c.rect(6, 13, 25, 13, 'Y') # 金手指
|
|
|
|
|
|
for x in range(6, 26, 2):
|
|
|
|
|
|
c.put(x, 14, 'Y')
|
|
|
|
|
|
c.rect(30, 3, 30, 11, 'k') # 挡板
|
|
|
|
|
|
if burnt:
|
|
|
|
|
|
for x, y in [(7, 5), (8, 6), (10, 9), (19, 6), (20, 7), (22, 9), (14, 8), (15, 7), (24, 5)]:
|
|
|
|
|
|
c.put(x, y, 'c')
|
|
|
|
|
|
c.rect(2, 4, 29, 4, 'e')
|
|
|
|
|
|
for x, y in [(12, 3), (13, 3), (17, 11), (26, 10)]:
|
|
|
|
|
|
c.put(x, y, 'c')
|
|
|
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rig():
|
2026-11-03 10:05:06 +08:00
|
|
|
|
"""矿架(48×48,3×3 格):和游戏里其他机器一样的略微俯视(3/4 视角)——顶面是上窄下宽的梯形,
|
|
|
|
|
|
下面是正面,正面里四层显卡位,左下角电源;光从左上来,左亮右暗;轮廓统一深紫、削掉尖角、底下两只脚和一圈阴影。"""
|
2026-11-03 09:11:46 +08:00
|
|
|
|
c = Canvas(48, 48)
|
2026-11-03 10:05:06 +08:00
|
|
|
|
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'
|
2026-11-03 09:11:46 +08:00
|
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def terminal():
|
|
|
|
|
|
"""交易终端(32×32,2×2 格):小型显示器 + 键盘,屏幕上是 K 线。"""
|
|
|
|
|
|
c = Canvas(32, 32)
|
|
|
|
|
|
c.box(3, 2, 28, 21, 'm')
|
|
|
|
|
|
c.rect(4, 3, 27, 3, 'l')
|
|
|
|
|
|
c.box(5, 4, 26, 18, 's')
|
|
|
|
|
|
candles = [(8, 12, 15), (11, 10, 14), (14, 11, 13), (17, 7, 12), (20, 8, 11), (23, 5, 10)]
|
|
|
|
|
|
for x, top, bot in candles:
|
|
|
|
|
|
c.rect(x, top, x, bot, 'S')
|
|
|
|
|
|
c.put(x, top - 1, 'T')
|
2026-11-03 10:01:16 +08:00
|
|
|
|
c.rect(3, 22, 28, 23, 'd') # 支架(拉通,保证每格中心点不透明)
|
2026-11-03 09:11:46 +08:00
|
|
|
|
c.box(2, 24, 29, 30, 'b') # 键盘
|
|
|
|
|
|
for y in (26, 28):
|
|
|
|
|
|
for x in range(4, 28, 2):
|
|
|
|
|
|
c.put(x, y, 'm')
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 10:01:16 +08:00
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-03 09:11:46 +08:00
|
|
|
|
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():
|
2026-11-03 10:01:16 +08:00
|
|
|
|
canvas = make()
|
|
|
|
|
|
check_cells(canvas, name)
|
|
|
|
|
|
canvas.png(os.path.join(sys.argv[2], name + '.png'), scale=6, bg=(0xA0, 0xA8, 0x9C))
|
2026-11-03 09:11:46 +08:00
|
|
|
|
print(sys.argv[2])
|
|
|
|
|
|
else:
|
|
|
|
|
|
out = os.path.normpath(os.path.join(here, '..', 'src', 'Decentralized', 'ModFiles', 'Textures', 'Items'))
|
|
|
|
|
|
for name, make in SPRITES.items():
|
2026-11-03 10:01:16 +08:00
|
|
|
|
canvas = make()
|
|
|
|
|
|
check_cells(canvas, name)
|
|
|
|
|
|
canvas.png(os.path.join(out, name + '.png'))
|
2026-11-03 09:11:46 +08:00
|
|
|
|
print(out)
|