Terminal: monitor with a top face and a trapezoid keyboard. Graphics card: a metal bracket with ports, two fans, brand stripe, gold fingers with a PCIe notch and power connectors on top, now 2x2 cells. Rig: two slots by default, an upgrade slot that takes the next level's electronics (4 common, 1 rare, 2 rare) and consumes them at once; the card grid reshapes with the slot count, which is kept on the rig's tags. Verified in game, including the slot limit, the upgrade items, five cards, and save and reload. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
317 lines
14 KiB
Python
317 lines
14 KiB
Python
#!/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)
|