Атлас Тени: запечённый набор вместо спрайтов стража
Оригинал кладёт спрайт дважды — прозрачным блитом в x и XOR-блиттером в x+1; пакетный блит так не умеет, поэтому результат запечён упаковщиком. Две половины, как и у оригинала: sk* — кадры вне боя (спрайты Кида), sf* — кадры 150..189 (SHADOW.DAT, тоже графика Кида). 251 спрайт, 32 EMM-страницы, палитра 16 цветов в 0xA0..0xAF. Закрывает BUG-SHADOW-SET: раньше тип 4 уходил в guard_names, и Тень в бою дралась серым стражем. Грабля: kid.pal заливает все 256 записей и затирает слоты Тени — палитра вынесена в pop_shadow_pal_apply рядом с pop_bg_pal_apply. Проверено в MAME на 6-м уровне: силуэт с контуром, как в оригинале.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_shadow.py — упаковщик спрайтов ТЕНИ.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНЫЙ НАБОР. Оригинал не хранит спрайтов Тени вовсе: он кладёт
|
||||
обычный спрайт ДВАЖДЫ — в x обычным прозрачным блитом, и в x+1 «блиттером
|
||||
XOR» (draw_objtable_item, seg008.c:1600). Наш пакетный блит так не умеет,
|
||||
поэтому результат запекается заранее, здесь.
|
||||
|
||||
ЧТО ИМЕННО ДЕЛАЕТ ОРИГИНАЛ (проверено по исходнику, не по памяти):
|
||||
|
||||
* `blitters_2_or` — НЕ побитовое ИЛИ. В SDLPoP это обычный блит с
|
||||
colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306), то
|
||||
есть один в один наш прозрачный блит.
|
||||
* `blitters_3_xor` работает по 24-битному RGB, а не по индексам палитры
|
||||
(blit_xor, seg009.c:3190: конвертация в 24 бита, затем `*p_dest ^=
|
||||
*p_src` побайтно). Прозрачности у него нет — XOR'ится весь
|
||||
прямоугольник, но прозрачный пиксель это чёрный 0x000000, а XOR с
|
||||
нулём ничего не меняет.
|
||||
|
||||
Отсюда своя палитра: XOR двух цветов игровой палитры даёт цвет, которого в
|
||||
ней нет.
|
||||
|
||||
ЕДИНСТВЕННОЕ РАСХОЖДЕНИЕ. XOR считается по тому, что УЖЕ на экране, то
|
||||
есть зависит от фона. Разбор по пикселям: от фона зависит только кайма в
|
||||
один пиксель по ЛЕВЫМ кромкам силуэта (там, где спрайт прозрачен в x, но
|
||||
непрозрачен в x−1). На чёрном фоне `фон XOR цвет == цвет`, и запечка
|
||||
точна; Тень почти всегда на чёрном. Мы печём именно этот случай.
|
||||
|
||||
ДВА НАБОРА В ОДНОМ АТЛАСЕ. Набор спрайтов у оригинала выбирает НЕ
|
||||
персонаж, а поле `sword` самого кадра: load_frame_to_obj (seg008.c:1752)
|
||||
держит chtab_base жёстко равным chtab_2 и прибавляет `sword >> 6`. У всех
|
||||
кадров frame_table_kid эти биты нулевые, у всех кадров frame_tbl_guard —
|
||||
0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533), то
|
||||
есть идёт через chtab_5, а chtab_5 на 12-м уровне грузится из SHADOW.DAT
|
||||
(tbl_guard_type[12] == 4, seg000.c:1117) — и это графика КИДА в боевых
|
||||
позах, а не стража. Поэтому печём обе половины:
|
||||
|
||||
KID — data/KID/res(401+id).png, id 0..218 -> sk0..skN.atl
|
||||
SHADOW — data/SHADOW/res(751+id).png, id 0..31 -> sf0..sfM.atl
|
||||
|
||||
Выход: poc/res/shadow/sk*.atl + sf*.atl + pop_shadow_atlas.h (палитра
|
||||
внутри заголовка — 16 записей, её заливает приложение через gfx_pal_load).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import pop_pack_bg as B
|
||||
from pop_pack_kid import transpose_cols
|
||||
|
||||
DATA = os.path.join(HERE, "..", "SDLPoP", "data")
|
||||
OUT_DIR = os.path.join(HERE, "..", "poc", "res", "shadow")
|
||||
PAL_BASE = 0xA0 # слоты Sprinter под Тень: 0xA0..0xAF
|
||||
NCOL = 16 # столько цветов в палитре Тени
|
||||
SHIFT = 3 # sk[id>>3], idx id&7 — как у Kid (кадры крупные)
|
||||
MASK = (1 << SHIFT) - 1
|
||||
|
||||
# Половины набора: (каталог, база ресурса, префикс выходных файлов).
|
||||
# Off-by-one тот же, что у Kid и стража: images[0] = res(база+1), значит
|
||||
# frame.image = N -> res(база+1+N) (см. pop_pack_kid.load_kid).
|
||||
HALVES = (("KID", 400, "sk"),
|
||||
("SHADOW", 750, "sf"))
|
||||
|
||||
|
||||
def frames(dirname, base):
|
||||
"""Все кадры половины: [(id, w, h, [[rgb|None]...])] — уже после
|
||||
наложения XOR, в честном RGB."""
|
||||
out = []
|
||||
id = 0
|
||||
while True:
|
||||
path = os.path.join(DATA, dirname, f"res{base + 1 + id}.png")
|
||||
if not os.path.exists(path):
|
||||
break
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
B.die(f"{path}: mode {im.mode}, ожидался P")
|
||||
pal = im.getpalette()[:NCOL * 3]
|
||||
rgb = [tuple(pal[i * 3:i * 3 + 3]) for i in range(NCOL)]
|
||||
w, h = im.size
|
||||
px = im.load()
|
||||
grid = []
|
||||
for y in range(h):
|
||||
# Ширина на один больше: второй проход кладётся в x+1, и
|
||||
# суммарный след занимает [x, x+w].
|
||||
row = [None] * (w + 1)
|
||||
for x in range(w): # проход 1: обычный блит
|
||||
i = px[x, y]
|
||||
if i:
|
||||
row[x] = rgb[i]
|
||||
for x in range(w): # проход 2: XOR в x+1
|
||||
i = px[x, y]
|
||||
if not i:
|
||||
continue # чёрный: XOR — не операция
|
||||
s = rgb[i]
|
||||
t = x + 1
|
||||
row[t] = s if row[t] is None else tuple(a ^ b for a, b in zip(row[t], s))
|
||||
grid.append(row)
|
||||
out.append((id, w + 1, h, grid))
|
||||
id += 1
|
||||
return out
|
||||
|
||||
|
||||
def histogram(halves):
|
||||
h = {}
|
||||
for _, fr in halves:
|
||||
for _, _, _, grid in fr:
|
||||
for row in grid:
|
||||
for c in row:
|
||||
if c is not None:
|
||||
h[c] = h.get(c, 0) + 1
|
||||
return h
|
||||
|
||||
|
||||
def _d2(a, b):
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
|
||||
|
||||
|
||||
def _kmeans(pts, k, init):
|
||||
"""Взвешенный Ллойд по СПИСКУ ЦВЕТОВ с весами (их полсотни, не пиксели)."""
|
||||
cent = list(init)
|
||||
for _ in range(80):
|
||||
groups = [[] for _ in cent]
|
||||
for c, n in pts:
|
||||
groups[min(range(len(cent)), key=lambda j: _d2(c, cent[j]))].append((c, n))
|
||||
new = []
|
||||
for j, g in enumerate(groups):
|
||||
if not g:
|
||||
new.append(cent[j])
|
||||
continue
|
||||
w = sum(n for _, n in g)
|
||||
new.append(tuple(int(round(sum(c[i] * n for c, n in g) / w)) for i in range(3)))
|
||||
if new == cent:
|
||||
break
|
||||
cent = new
|
||||
return cent
|
||||
|
||||
|
||||
def build_palette(hist):
|
||||
"""16 цветов ГИБРИДОМ: часть слотов — самые частые цвета ТОЧНО, остаток
|
||||
— взвешенные кластеры хвоста.
|
||||
|
||||
Почему не «просто самые частые»: тогда редкие цвета уезжают в далёкий
|
||||
ближайший, и грубых промахов (сдвиг >90 по евклиду в RGB при максимуме
|
||||
441) получается впятеро больше — 381 пиксель против 76 на все 251
|
||||
кадр (замер 2026-08-20). Почему не чистый k-means: он двигает вообще
|
||||
все цвета, включая массовые.
|
||||
|
||||
Разделение слотов подбираем перебором по критерию «сначала минимум
|
||||
грубых промахов, потом заметных, потом худшая ошибка»."""
|
||||
items = sorted(hist.items(), key=lambda kv: -kv[1])
|
||||
|
||||
def stats(pal):
|
||||
bad = worst = mid = big = 0
|
||||
for c, n in items:
|
||||
e = min(_d2(c, p) for p in pal) ** 0.5
|
||||
if e > 0:
|
||||
bad += n
|
||||
worst = max(worst, e)
|
||||
if e >= 90:
|
||||
big += n
|
||||
elif e >= 40:
|
||||
mid += n
|
||||
return bad, mid, big, worst
|
||||
|
||||
best = None
|
||||
for m in range(1, NCOL): # m слотов под хвост
|
||||
keep = [c for c, _ in items[:NCOL - m]]
|
||||
rest = [(c, n) for c, n in items[NCOL - m:]]
|
||||
if not rest:
|
||||
continue
|
||||
pal = keep + _kmeans(rest, m, [c for c, _ in rest[:m]])
|
||||
bad, mid, big, worst = stats(pal)
|
||||
score = (big, mid, worst)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, m, pal, (bad, mid, big, worst))
|
||||
_, m, pal, st = best
|
||||
# Чёрный обязан быть ТОЧНЫМ и лежать в слоте 0: это XOR-погашенная
|
||||
# середина силуэта, больше половины всех пикселей Тени.
|
||||
j = min(range(len(pal)), key=lambda i: _d2((0, 0, 0), pal[i]))
|
||||
pal[j] = (0, 0, 0)
|
||||
pal[0], pal[j] = pal[j], pal[0]
|
||||
bad, mid, big, worst = st
|
||||
tot = sum(hist.values())
|
||||
print(f" палитра: {NCOL - m} частых точно + {m} кластеров хвоста; "
|
||||
f"из {len(items)} цветов изменено {bad} px из {tot} "
|
||||
f"({100.0 * bad / tot:.2f} %), заметно {mid}, сильно {big}, худшая {worst:.0f}")
|
||||
return pal
|
||||
|
||||
|
||||
def encode(grid, w, h, pal):
|
||||
"""RGB-сетка -> байты атласа: нет пикселя -> 0xFF, цвет -> PAL_BASE + k.
|
||||
Чёрный — НАСТОЯЩИЙ цвет Тени (слот 0), а не прозрачность."""
|
||||
row = bytearray()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
c = grid[y][x]
|
||||
if c is None:
|
||||
row.append(0xFF)
|
||||
else:
|
||||
row.append(PAL_BASE + min(range(len(pal)), key=lambda k: _d2(c, pal[k])))
|
||||
return transpose_cols(w, h, bytes(row))
|
||||
|
||||
|
||||
def write_header(path, counts, pal):
|
||||
vals = []
|
||||
for (r, g, b) in pal:
|
||||
vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
|
||||
lines = [
|
||||
"/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
|
||||
" * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Две половины, потому что и у оригинала их две (см. шапку",
|
||||
" * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
|
||||
" * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.",
|
||||
" * Какая половина нужна, приложение уже знает — тем же условием,",
|
||||
" * что выбирает таблицу кадров (pop_frame_tbl_is_guard).",
|
||||
" *",
|
||||
" * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход",
|
||||
" * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].",
|
||||
" * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.",
|
||||
" */",
|
||||
"#ifndef POP_SHADOW_ATLAS_H",
|
||||
"#define POP_SHADOW_ATLAS_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
"#define POP_SHADOW_SHIFT %d" % SHIFT,
|
||||
"#define POP_SHADOW_MASK %d" % MASK,
|
||||
"#define POP_SHADOW_PAL_BASE 0x%02X /* слоты Sprinter под Тень */" % PAL_BASE,
|
||||
"",
|
||||
"/* Половина «вне боя» — кадры Кида (chtab_2). */",
|
||||
"#define POP_SHADOW_KID_PAGES %d" % counts["sk"],
|
||||
"/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
|
||||
"#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
|
||||
"",
|
||||
"static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
|
||||
"static const uint8_t pop_shadow_pal[64] = {",
|
||||
" " + ",".join("0x%02X" % v for v in vals) + ",",
|
||||
"};",
|
||||
"",
|
||||
"#endif",
|
||||
"",
|
||||
]
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
print(f" pop_shadow_atlas.h -> {path}")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
halves = [(prefix, frames(d, base)) for d, base, prefix in HALVES]
|
||||
for (d, base, prefix), (_, fr) in zip(HALVES, halves):
|
||||
if not fr:
|
||||
B.die(f"нет спрайтов в data/{d} (SHADOW.DAT распакован?)")
|
||||
print(f" {d}: {len(fr)} кадров")
|
||||
pal = build_palette(histogram(halves))
|
||||
|
||||
counts, total, nspr = {}, 0, 0
|
||||
for prefix, fr in halves:
|
||||
pages = {}
|
||||
for id, w, h, grid in fr:
|
||||
pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, encode(grid, w, h, pal))
|
||||
nspr += 1
|
||||
npg = max(pages) + 1
|
||||
counts[prefix] = npg
|
||||
for pg in range(npg):
|
||||
nm = f"{prefix}{pg}.atl"
|
||||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
|
||||
total += sz
|
||||
print(f" {prefix}*.atl: {len(fr)} спрайтов, {npg} EMM-страниц")
|
||||
write_header(os.path.join(HERE, "..", "roomtest", "pop_shadow_atlas.h"), counts, pal)
|
||||
print(f"ИТОГО: {nspr} спрайтов Тени, "
|
||||
f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user