d7d18aef77
L7-FEATHER: тип 3 (уровень 7, комната 1) больше не пустой TODO. - pop_map.c: pop_feather — счётчик кадров эффекта (POP_FEATHER_FRAMES = 225, ванильный порог do_timers seg003:0517; привязку к звуку взять неоткуда). fall_accel — ускорение 1 / потолок 4 (seg006:057C) вместо 3 / 33. proc_get_object case 3: взвести эффект + зелёная вспышка на 3 кадра. - pop_kid.c: опкод JMP_IF_FEATHER (0xF7) больше не пропускает адрес безусловно — под пером прыгает по нему, то есть seqtbl уводит падение и удар в ветки stepfloat/bumpfloat (плавные кадры, без урона). - roomtest.c: pop_flash_red -> pop_flash_color (жёлтый/красный/ЗЕЛЁНЫЙ); сброс pop_feather в pop_start_level (seg003:189). - Цвет пузырька по ТИПУ зелья (seg008:652), чего у нас не было вовсе: 3/4 зелёный, 5/6 СИНИЙ, остальные красный. Mono-блиттера с параметром цвета в libbgi нет, поэтому цвет запекается при упаковке: pop_pack_bg.py кладёт те же 7 кадров ещё дважды (id 30..36 зелёные, 40..46 синие), pop_potion_draw выбирает набор. Атлас 23 -> 37 спрайтов (+423 Б). - Синее зелье «−HP» приведено к оригиналу (seg006:1892): своей вспышки не ставит (красный кадр даёт общий flash_if_hurt — иначе экран красился дважды), а на уровне зелий забирает ПОЛОВИНУ запаса HP. Такое зелье стоит уже на пройденном уровне 2 (комната 13, тайл (1,3)), а также ур.8 комн.2 и весь ур.15 — до сих пор оно было красным. Тесты: phys_feather_fall_is_slow_and_harmless (обычное падение с двух рядов разгоняется и стоит HP, под пером скорость <= 4 и HP целое); tests-host 5/5 (1727 в [phys]), size-check OK. Расхождения с ванилью — в docs/impl_diff.md (перо ловит только Кида; синее зелье без своей вспышки). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
622 lines
37 KiB
Python
622 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
pop_pack_bg.py — упаковщик спрайтов СТАТИЧЕСКОГО ФОНА PoP (Шаг 2 порта).
|
||
|
||
Что делает:
|
||
1. Определяет РЕАЛЬНО используемый набор спрайтов фона (прогоняет
|
||
render_room.py по всем комнатам всех уровней, собирает (chtab,id)).
|
||
2. Грузит исходные PNG (VDUNGEON→VPALACE каскад, как в игре), переводит
|
||
16-цветный индексированный пиксель в 8bpp getimage-блоб:
|
||
индекс 0 -> 0xFF (прозрачность getimage)
|
||
индекс i -> 0x50+i (env, chtab_6) / 0x60+i (wall, chtab_7)
|
||
3. Пакует в атласы .atl с ПРЯМОЙ адресацией (idx = id), каталог любого
|
||
размера (блобы кладутся ПОСЛЕ каталога — загрузчик atlas_image читает
|
||
offset явно, лимит 19 из libbgi/mkatlas.py не действует). libbgi НЕ
|
||
трогаем. Дырки в каталоге (неиспользуемые id) = фейковые записи,
|
||
ссылающиеся на общий пустой 0×0-блоб (осознанная трата места в EMM
|
||
ради нулевых remap-таблиц в W2 — требование пользователя).
|
||
4. Собирает палитру Sprinter (.pal, записи B,G,R,0) из res200.pal (env
|
||
-> слоты 0x50..0x5F) и res360.pal (wall -> 0x60..0x6F). VGA 6-бит
|
||
каналы масштабируются в 8-бит.
|
||
5. Пишет C-заголовок с раскладкой (имена файлов, SHIFT, число страниц,
|
||
набор fore-id).
|
||
|
||
Раскладка (прямая адресация, SHIFT=5):
|
||
ENV фон : 5 страниц, страница = id>>5, idx = id&31
|
||
WALL : 1 страница, idx = id
|
||
FORE : 1 страница (спрайты «перед персонажем»: фронты столбов/
|
||
ворот/дебриса), idx = id — отдельный атлас (жизненный цикл
|
||
другой: рисуются каждый кадр как sprite_t, не запекаются).
|
||
W2-цена: 7 хэндлов atlas_t (~21 Б), ноль таблиц id->позиция.
|
||
|
||
Выход: в applications/PoP/poc/res/bg/ — *.atl + pop_bg.pal + pop_bg_atlas.h
|
||
"""
|
||
import os
|
||
import sys
|
||
from PIL import Image
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
import render_room as R # noqa: E402 (экстрактор + каскад загрузки)
|
||
|
||
OUT_DIR = os.path.join(HERE, "..", "poc", "res", "bg")
|
||
|
||
# Спрайты FORE-слоя (add_foretable в seg008 — «перед персонажем»): фронты
|
||
# столбов, ворот, дебриса. См. tile_table.fore_id + анализ pop_background_strategy.
|
||
# 119..123 — фронт чомпера и его кровь; 77 — фронт ЗЕРКАЛА (MIRROR_ENV_IDS).
|
||
#
|
||
# id 12 ЗДЕСЬ НЕТ намеренно. В tile_table он стоит как fore_id ЗЕЛЬЯ
|
||
# (0x0A), но зелье рисуется из chtab_1 (`add_foretable(id_chtab_1_...)`,
|
||
# seg008 draw_tile_fore; у нас — pop_potion_flask), а в chtab_6 под этим
|
||
# номером лежит ПРАВАЯ ЧАСТЬ АРКИ БАЛКОНА (32x62), и она идёт в backtable:
|
||
# `BT room=18 r=1 c=7 ch=6 id=12` в трассе оригинала. Пока 12 был в этом
|
||
# списке, он уезжал в fore-атлас, а env-страница получала дырку — правая
|
||
# половина портала не рисовалась вовсе (BUG-BALCONY-RIGHT).
|
||
FORE_ENV_IDS = {9, 49, 88, 91, 95, 100} | {106, 107, 108, 109, 110} \
|
||
| {119, 120, 121, 122, 123} | {77}
|
||
|
||
# Пол-оверлей подъёма (floor_left_overlay, draw_floor_overlay seg008): рисуется
|
||
# рантаймом поверх нижней части Kid при подтягивании на пол у кромки — НЕ
|
||
# попадает в used render_room, добавляем явно в env-фон. (32 обычно уже есть.)
|
||
CLIMB_OVERLAY_ENV_IDS = {32, 150, 151}
|
||
|
||
# Дрожащие/падающие кадры loose-плиты (loose_fram_left/right/bottom, seg008):
|
||
# 41/42/43 = покой (уже в used из render_room), 69-74 = вибрация/раздвоение —
|
||
# используются ТОЛЬКО динамикой (shake/fall), в статический render_room НЕ
|
||
# попадают, поэтому добавляем явно. Без них дрожащая плита невидима.
|
||
LOOSE_ANIM_ENV_IDS = {69, 70, 71, 72, 73, 74}
|
||
|
||
# Кадры ВЫДВИЖЕНИЯ пик (seg008): три части — left (128-132, своя ячейка,
|
||
# draw_tile_anim), right (134-138, правый сосед, draw_tile_anim_right), fore
|
||
# (139-143, поверх Kid, draw_tile_fore). Рисуются ТОЛЬКО динамикой
|
||
# (animate_spike); в уровнях пики статически убраны (modif=0 → кадр 0),
|
||
# поэтому в used render_room НЕ попадают. Без них выдвинутые пики невидимы.
|
||
SPIKE_ANIM_ENV_IDS = {128, 129, 130, 131, 132, # left
|
||
134, 135, 136, 137, 138, # right
|
||
139, 140, 141, 142, 143} # fore
|
||
|
||
# Кадры ЧОМПЕРА (seg008:638 chomper_fram_bot/top/for + кровь). В уровнях
|
||
# чомпер стоит с modif = 0, а chomper_fram1[0] = 3 — значит render_room
|
||
# (он анимацию пропускает вовсе, см. render_room.py:611) не кладёт в атлас
|
||
# НИ ОДНОГО кадра, и чомпер не виден даже в покое. Поэтому весь набор —
|
||
# явно. Низ и верх идут в backtable (ПОД персонажем), фронт — в foretable
|
||
# (ПОВЕРХ), поэтому фронтовые id продублированы в FORE_ENV_IDS.
|
||
CHOMPER_BOT_IDS = {101, 102, 103, 104, 105} # chomper_fram_bot
|
||
CHOMPER_TOP_IDS = {111, 112, 113} # chomper_fram_top (фазы 2..4)
|
||
CHOMPER_FORE_IDS = {106, 107, 108, 109, 110} # chomper_fram_for
|
||
|
||
# Кровь на сомкнутых челюстях (modif & 0x80): chomper_num + 114 в backtable и
|
||
# + 119 в foretable, обе — MONO-блитом цветом 12 (blitters_4Ch_mono_12), то
|
||
# есть силуэтом, как пузырёк зелья. Появляется после смерти Кида в чомпере.
|
||
CHOMPER_BLOOD_IDS = {114, 115, 116, 117, 118}
|
||
CHOMPER_BLOOD_FORE_IDS = {119, 120, 121, 122, 123}
|
||
CHOMPER_BLOOD_COLOR = None # ставится ниже, когда известен VGA16_PAL_BASE
|
||
|
||
# Кадры ОТКРЫТИЯ ворот (portcullis, DOOR_FRAM_SLICE + gate pieces 50/51,
|
||
# seg008 draw_gate): 50-59 + 67. В уровнях ворота статически ЗАКРЫТЫ
|
||
# (низкий modif → используются только нижние срезы 50-53); срезы более
|
||
# открытых состояний (54-59, 67) нужны анимации открытия — добавляем явно.
|
||
GATE_ANIM_ENV_IDS = {50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 67}
|
||
# ВЕРХ ворот, который рисует тайл НАД ними (draw_tile_anim_topright,
|
||
# seg008:0568): маска 68 (mono, чёрным — «продавить» фон под створкой) и
|
||
# кадр door_fram_top[(modifier>>2) % 8] = 60..67 по открытости. Без них
|
||
# над воротами остаётся чёрный треугольник (видно у шва во дворце).
|
||
GATE_TOP_ENV_IDS = {60, 61, 62, 63, 64, 65, 66, 67}
|
||
GATE_TOP_MASK_ID = 68
|
||
|
||
|
||
# НАЖАТАЯ кнопка-closer (get_tile_to_draw seg008:2FE: при таймере связи >1
|
||
# tiles_6_closer рисуется как tiles_5_stuck). tile_table[5] = база 41 (уже
|
||
# есть), правая грань 35, нижняя 36 — оба id встречаются ТОЛЬКО в этом
|
||
# динамическом состоянии, статически тайла 5 в уровнях нет, значит в used
|
||
# render_room они не попадают. Без них у нажатой кнопки чёрный провал на
|
||
# месте нижней грани и потерянная правая грань.
|
||
STUCK_ENV_IDS = {35, 36}
|
||
|
||
# Левая половина кнопки-opener, когда СЛЕВА пусто (draw_tile_base seg008:0A8E:
|
||
# curr_tile==15 && tile_left==0 && level_type==0 → id 148 вместо базы 147).
|
||
# render_room этой ветки не знает, поэтому добавляем явно.
|
||
OPENER_NOFLOOR_ENV_IDS = {148}
|
||
|
||
# ---- chtab_1 (flame/sword/potion), база ресурса 150, папка PRINCE ----
|
||
# Зелье (seg008 draw_tile_fore/draw_tile_anim): склянка id 12 (малая) / 13
|
||
# (большая, типы 2..4), кадры пузырька 16..22 (potion_fram_bubb) и его маска
|
||
# 23. Пузырёк и маска рисуются MONO-блитом (одним цветом), поэтому пакуем их
|
||
# силуэтами уже в нужном цвете: маска — чёрный env[0], пузырёк — env[12]
|
||
# (красный, тип «лечение»). Цвета других типов (9 синий / 10 зелёный) —
|
||
# TODO, когда появятся такие зелья.
|
||
POT_DIR_NAME = "PRINCE"
|
||
POT_FLAME_IDS = tuple(range(1, 10)) # пламя факела: modifier+1 (seg008:560)
|
||
POT_FLASK_IDS = (12, 13)
|
||
POT_BUBBLE_IDS = (16, 17, 18, 19, 20, 21, 22)
|
||
POT_MASK_ID = 23
|
||
POT_PAL_BASE = 0x40 # 16 цветов chtab_1 -> слоты 0x40..0x4F
|
||
# MONO-блит (method_3_blit_mono, seg009:3040) красит спрайт цветом из ОБЩЕЙ
|
||
# 16-цветной палитры экрана, а не из палитры chtab. Кладём её в 0x30..0x3F.
|
||
VGA16_PAL_BASE = 0x30
|
||
# Два цвета игра ПРАВИТ на старте (init_game_main, seg000:164) — стандартные
|
||
# VGA-значения там не годятся:
|
||
# 6 -> #C09850 (0x30,0x26,0x14) «palace wall pattern» — швы дворцовой кладки
|
||
# 12 -> #E00030 (0x38,0x00,0x0C) «blood, hurt flash» — кровь и вспышка урона
|
||
# Проверено дампом палитры живого SDLPoP: PAL[6]=48,38,20 PAL[12]=56,0,12.
|
||
VGA16 = [(0,0,0),(0,0,42),(0,42,0),(0,42,42),(42,0,0),(42,0,42),(48,38,20),
|
||
(42,42,42),(21,21,21),(21,21,63),(21,63,21),(21,63,63),(56,0,12),
|
||
(63,21,63),(63,63,21),(63,63,63)] # 6-битные каналы, как в .pal
|
||
POT_MASK_COLOR = VGA16_PAL_BASE + 0 # чёрный (blitters_40h_mono без цвета)
|
||
POT_BUBBLE_COLOR = VGA16_PAL_BASE + 12 # ярко-красный (color 12: «лечение»)
|
||
# Цвет пузырька зависит от ТИПА зелья (draw_tile_anim, seg008:652): типы 3/4
|
||
# (перо / переворот) — зелёный, 5/6 (вред / открыть) — синий, остальные —
|
||
# красный. У нас спрайт красится при УПАКОВКЕ (mono-блиттера с параметром
|
||
# цвета в libbgi нет), поэтому каждый цвет — свой набор кадров: те же семь
|
||
# силуэтов под сдвинутыми id. Синий появится вместе с зельями 5/6.
|
||
POT_BUBBLE_GREEN_COLOR = VGA16_PAL_BASE + 10 # color 10 (seg008:663)
|
||
POT_BUBBLE_GREEN_BASE = 30 # id 30..36 = кадры 16..22
|
||
POT_BUBBLE_BLUE_COLOR = VGA16_PAL_BASE + 9 # color 9 (seg008:658)
|
||
POT_BUBBLE_BLUE_BASE = 40 # id 40..46 = кадры 16..22
|
||
CHOMPER_BLOOD_COLOR = VGA16_PAL_BASE + 12 # тот же mono-цвет 12 (seg008:678)
|
||
|
||
# Кладка ДВОРЦА (wall_pattern, seg008:1928, ветка palace): тело стены —
|
||
# сплошные заливки цветом из palace_wall_colors, а поверх идут ПЯТЬ
|
||
# разделителей chtab_7 id 3..17 (prandom(2) + 3/6/9/12/15), и рисуются они
|
||
# blitters_46h_mono_6 — mono-блитом ОДНИМ цветом 6 общей 16-цветной палитры
|
||
# экрана. В подземелье те же id — обычные спрайты кирпичей (wall_fram_main/
|
||
# bottom), поэтому силуэтом пакуем ТОЛЬКО дворцовый набор.
|
||
PALACE_WALL_MONO_IDS = set(range(3, 18))
|
||
PALACE_WALL_MONO_COLOR = VGA16_PAL_BASE + 6
|
||
|
||
|
||
# Основание факела (seg008:489 draw_tile_right: при левом соседе-факеле
|
||
# рисуется chtab_6 id 146). В статический render_room не попадает.
|
||
TORCH_ENV_IDS = {146}
|
||
|
||
# ---- Два тайлсета: подземелье и дворец --------------------------------
|
||
# Оригинал грузит РАЗНЫЙ .DAT под один и тот же набор id:
|
||
# tbl_envir_ki[tbl_level_type[level]] -> VDUNGEON / VPALACE (seg000:1108),
|
||
# то есть id спрайта от набора НЕ зависит — меняется только картинка и
|
||
# палитра (env 0x50..0x5F, wall 0x60..0x6F). Поэтому оба набора пакуются
|
||
# по ОДНОМУ И ТОМУ ЖЕ объединению id: раскладка id -> (страница, idx)
|
||
# остаётся одна, и коду достаточно подменить ИМЕНА файлов.
|
||
#
|
||
# Порядок каталогов — каскад: сначала «свой» набор, потом чужой как
|
||
# фолбэк (в распакованном data/ несколько id есть только в одном из них:
|
||
# res230/231/348 только в VDUNGEON, два десятка — только в VPALACE).
|
||
SETS = {
|
||
"dungeon": ("pop_", lambda: (R.VDUNGEON_DIR, R.VPALACE_DIR)),
|
||
"palace": ("pal_", lambda: (R.VPALACE_DIR, R.VDUNGEON_DIR)),
|
||
}
|
||
SRC_DIRS = None # ставит main() по выбранному набору
|
||
|
||
# Спрайты, которые рисуются ТОЛЬКО на паласных уровнях (seg008):
|
||
# 78, 80, 82 doortop_fram_bot (низ верха двери, draw_tile_right)
|
||
# 81, 83 doortop_fram_top (draw_tile_topright)
|
||
# 84 полоска на стене (draw_tile_right, tiles_20_wall)
|
||
# 145 stripe_id соседнего тайла (draw_tile_right)
|
||
# render_room про них не знает (он рисует по подземельной ветке), поэтому
|
||
# добавляем явно — иначе при переключении набора в этих местах будет дыра.
|
||
PALACE_ENV_IDS = {78, 80, 81, 82, 83, 84, 145}
|
||
|
||
# ЗЕРКАЛО (tile_table[0x0D] = база 75, фронт 77; 42/43 — общие грани пола).
|
||
# Тайла 13 НЕТ НИ В ОДНОМ уровне статически (проверено перебором всех 15
|
||
# res200N.bin): оригинал СТАВИТ его в рантайме, когда дверь выхода уровня 4
|
||
# дорисовала открытие — animate_leveldoor, seg007:0457:
|
||
# curr_room_tiles[curr_tilepos] = tiles_13_mirror;
|
||
# Значит в used от render_room эти id не попадают и без явного добавления на
|
||
# месте зеркала был бы чёрный провал (те же грабли, что у нажатой кнопки и
|
||
# дрожащей плиты — memory pop_atlas_dynamic_ids).
|
||
MIRROR_ENV_IDS = {75, 77}
|
||
|
||
ENV_SHIFT = 4 # env фон: страница = id>>SHIFT, idx = id&MASK.
|
||
# Было 5: паласный набор добавляет куски верха
|
||
# двери (78..84), и страница 2 переваливала за
|
||
# 16 КБ. При 4 обе раскладки укладываются, цена
|
||
# — 10 страниц EMM на набор вместо 5.
|
||
ENV_MASK = (1 << ENV_SHIFT) - 1
|
||
|
||
# ---- .atl формат (== libbgi, но каталог не ограничен 19) --------------
|
||
ATL_MAGIC = b"SPA1"
|
||
ATL_DIR_OFF = 0x68 # _ATL_DIR_OFF
|
||
ATL_MIN_SIZE = 0x100 # atlas_load требует n >= _ATL_DATA_OFF
|
||
PAGE_SIZE = 16384
|
||
|
||
|
||
def die(msg):
|
||
sys.exit(f"pop_pack_bg: {msg}")
|
||
|
||
|
||
# ---- 1. Набор используемых спрайтов ----------------------------------
|
||
def collect_used():
|
||
"""Прогон render_room по всем комнатам -> множество (chtab, id)."""
|
||
used = set()
|
||
orig = R.load_sprite
|
||
|
||
def spy(id, chtab=R.CHTAB_ENV, reason=""):
|
||
img = orig(id, chtab, reason)
|
||
if img is not None and id > 0:
|
||
used.add((chtab, id))
|
||
return img
|
||
|
||
R.load_sprite = spy
|
||
for lvl in range(1, 15):
|
||
fn = os.path.join(R.LEVEL_DIR, f"res{2000 + lvl}.bin")
|
||
if not os.path.exists(fn):
|
||
continue
|
||
lv = R.Level(open(fn, "rb").read())
|
||
for room in range(1, 25):
|
||
rm = R.Room(lv, room)
|
||
L = lv.links[room][0]
|
||
if L:
|
||
rm.left_room = R.Room(lv, L)
|
||
canvas = Image.new("RGBA", (320, 192), (0, 0, 0, 0))
|
||
for dr in (2, 1, 0):
|
||
for c in range(10):
|
||
R.draw_tile(canvas, rm, dr, c)
|
||
R.load_sprite = orig
|
||
return used
|
||
|
||
|
||
# ---- 2. Загрузка спрайта в 8bpp-блоб ---------------------------------
|
||
# Спрайты chtab_6, которые оригинал рисует БЕЗ прозрачности
|
||
# (blitters_0_no_transp): нижние грани тайлов (draw_tile_bottom, seg008:570) и
|
||
# нижние кадры loose (draw_loose, seg008:600). У 96/149 (грани кнопок) и 73/74
|
||
# (кадры loose) ПОСЛЕДНЯЯ строка — пиксель 0; в оригинале она заливается ЧЁРНЫМ
|
||
# поверх того, что сзади, а у нас 0 -> 0xFF делал её прозрачной, и сквозь неё
|
||
# просвечивал Kid (спуск/подъём у кромки кнопки). 43/36 полностью непрозрачны —
|
||
# для них флаг ничего не меняет, оставлены для полноты списка.
|
||
# Дверь уровня (draw_leveldoor, seg008:1D29): решётка створки (33 — слайс 4px,
|
||
# 34 — верх), лестница за ней (99 — низ, 144 — марш). render_room.py
|
||
# draw_leveldoor не реализует, поэтому в автосбор эти id не попадают.
|
||
LEVELDOOR_ENV_IDS = {33, 34, 99, 144}
|
||
|
||
# Куски двери уровня (draw_leveldoor) оригинал рисует blitters_0_no_transp:
|
||
# марш лестницы 144 обязан ЗАКРАШИВАТЬ поднявшуюся створку, иначе слева от
|
||
# лестницы сквозь него видна решётка (0 -> 0xFF делал фон спрайта прозрачным).
|
||
NO_TRANSP_ENV_IDS = {36, 43, 73, 74, 96, 149} | LEVELDOOR_ENV_IDS
|
||
|
||
|
||
def load_indexed(chtab, id, opaque=False, mono=None):
|
||
"""PNG (mode P, 16 цветов) -> (w, h, bytes 8bpp). Каскад по SRC_DIRS
|
||
(порядок задаёт выбранный тайлсет, см. SETS).
|
||
Пиксель 0 -> 0xFF (прозрач); i -> base+i (env 0x50 / wall 0x60).
|
||
|
||
opaque=True — пиксель 0 остаётся ЦВЕТОМ (base+0 = чёрный), а не ключом
|
||
прозрачности: порт blitters_0_no_transp (см. NO_TRANSP_ENV_IDS)."""
|
||
base_res = 360 if chtab == R.CHTAB_WALL else 200
|
||
pal_base = 0x60 if chtab == R.CHTAB_WALL else 0x50
|
||
fname = f"res{base_res + id}.png"
|
||
path = None
|
||
for d in SRC_DIRS:
|
||
p = os.path.join(d, fname)
|
||
if os.path.exists(p):
|
||
path = p
|
||
break
|
||
if path is None:
|
||
die(f"нет файла {fname}")
|
||
im = Image.open(path)
|
||
if im.mode != "P":
|
||
die(f"{fname}: mode {im.mode}, ожидался P (16-цветный)")
|
||
src = im.tobytes()
|
||
if mono is not None:
|
||
# MONO-блит (blitters_4Ch_mono_12): спрайт красится ОДНИМ цветом из
|
||
# 16-цветной палитры экрана — пакуем силуэтом, как пузырёк зелья.
|
||
out = bytes((0xFF if v == 0 else mono) for v in src)
|
||
elif opaque:
|
||
out = bytes((pal_base + v) for v in src)
|
||
else:
|
||
out = bytes((0xFF if v == 0 else pal_base + v) for v in src)
|
||
return im.width, im.height, out
|
||
|
||
|
||
def load_potion(id, mono=None, opaque=False):
|
||
"""chtab_1: res(150+id).png из data/PRINCE. mono=None — обычный спрайт
|
||
(палитра chtab_1 -> слоты POT_PAL_BASE+i); mono=цвет — силуэт этим цветом
|
||
(порт blitters_40h_mono); opaque=True — пиксель 0 остаётся ЦВЕТОМ
|
||
(порт blitters_0_no_transp, см. POT_FLAME_IDS)."""
|
||
path = os.path.join(os.path.dirname(R.VDUNGEON_DIR), POT_DIR_NAME,
|
||
f"res{150 + id}.png")
|
||
if not os.path.exists(path):
|
||
die(f"нет файла {path}")
|
||
im = Image.open(path)
|
||
if im.mode != "P":
|
||
die(f"res{150+id}: mode {im.mode}, ожидался P")
|
||
src = im.tobytes()
|
||
if mono is not None:
|
||
out = bytes((0xFF if v == 0 else mono) for v in src)
|
||
elif opaque:
|
||
out = bytes((POT_PAL_BASE + v) for v in src)
|
||
else:
|
||
out = bytes((0xFF if v == 0 else POT_PAL_BASE + v) for v in src)
|
||
return im.width, im.height, out
|
||
|
||
|
||
def blob(w, h, pix):
|
||
"""getimage-блоб: u16 w, u16 h (LE) + пиксели."""
|
||
return w.to_bytes(2, "little") + h.to_bytes(2, "little") + pix
|
||
|
||
|
||
# ---- 3. Пакер .atl (каталог любого размера) --------------------------
|
||
def pack_atlas(path, entries):
|
||
"""entries: dict idx -> (w, h, pix). Пустые idx (0..maxidx) заполняются
|
||
фейковой ссылкой на общий 0×0-блоб. Каталог count записей с 0x68,
|
||
блобы — сразу после каталога."""
|
||
if not entries:
|
||
die(f"{path}: пустой атлас")
|
||
count = max(entries) + 1
|
||
if count > 255:
|
||
die(f"{path}: count {count} > 255")
|
||
data_off = ATL_DIR_OFF + count * 8
|
||
|
||
blobs = bytearray()
|
||
empty_off = data_off + len(blobs) # общий 0×0-блоб для дырок
|
||
blobs += blob(0, 0, b"")
|
||
dir_bytes = bytearray(count * 8)
|
||
for idx in range(count):
|
||
e = entries.get(idx)
|
||
if e is None:
|
||
off = empty_off
|
||
fw = fh = 0
|
||
else:
|
||
w, h, pix = e
|
||
off = data_off + len(blobs)
|
||
blobs += blob(w, h, pix)
|
||
fw = min(w, 255)
|
||
fh = min(h, 255)
|
||
d = idx * 8
|
||
dir_bytes[d:d + 2] = off.to_bytes(2, "little")
|
||
dir_bytes[d + 2] = fw
|
||
dir_bytes[d + 3] = fh
|
||
dir_bytes[d + 4] = 1 # nx
|
||
dir_bytes[d + 5] = 1 # ny
|
||
# d+6,d+7 резерв
|
||
|
||
out = bytearray(data_off)
|
||
out[0:4] = ATL_MAGIC
|
||
out[4] = count
|
||
out[ATL_DIR_OFF:ATL_DIR_OFF + len(dir_bytes)] = dir_bytes
|
||
out += blobs
|
||
if len(out) < ATL_MIN_SIZE:
|
||
out += bytes(ATL_MIN_SIZE - len(out)) # atlas_load: n >= 0x100
|
||
if len(out) > PAGE_SIZE:
|
||
die(f"{path}: {len(out)} Б > {PAGE_SIZE} (одна EMM-страница)")
|
||
with open(path, "wb") as f:
|
||
f.write(out)
|
||
return count, len(out)
|
||
|
||
|
||
# ---- 4. Палитра ------------------------------------------------------
|
||
def scale6to8(v):
|
||
return (v * 255) // 63
|
||
|
||
|
||
def read_pal16(res, d):
|
||
b = open(os.path.join(d, res), "rb").read()
|
||
return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)]
|
||
|
||
|
||
def build_tileset_palette(out_path):
|
||
"""Палитра ТОЛЬКО тайлсета: 32 записи B,G,R,0 — env (16) затем wall (16).
|
||
Ею подменяются слоты 0x50..0x6F при смене типа уровня; полная игровая
|
||
палитра (kid.pal) при этом не трогается, потому что Кид, страж, меч и
|
||
зелья живут в других слотах (levels_plan §3). Порядок каталогов —
|
||
SRC_DIRS, то есть файл соответствует выбранному набору."""
|
||
pal = bytearray(32 * 4)
|
||
src = read_pal16("res200.pal", SRC_DIRS[0]) + read_pal16("res360.pal", SRC_DIRS[0])
|
||
for i, (r, g, b) in enumerate(src):
|
||
pal[i * 4:i * 4 + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||
with open(out_path, "wb") as f:
|
||
f.write(pal)
|
||
|
||
|
||
def build_palette(out_path):
|
||
"""256-записная палитра B,G,R,0: env@0x50..0x5F, wall@0x60..0x6F."""
|
||
def read_pal(res, d):
|
||
return read_pal16(res, d)
|
||
|
||
env = read_pal("res200.pal", R.VDUNGEON_DIR)
|
||
wall = read_pal("res360.pal", R.VDUNGEON_DIR)
|
||
pot = read_pal("res150.pal", os.path.join(os.path.dirname(R.VDUNGEON_DIR),
|
||
POT_DIR_NAME))
|
||
pal = bytearray(256 * 4)
|
||
for i, (r, g, b) in enumerate(env):
|
||
o = (0x50 + i) * 4
|
||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||
for i, (r, g, b) in enumerate(wall):
|
||
o = (0x60 + i) * 4
|
||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||
for i, (r, g, b) in enumerate(pot):
|
||
o = (POT_PAL_BASE + i) * 4
|
||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||
for i, (r, g, b) in enumerate(VGA16):
|
||
o = (VGA16_PAL_BASE + i) * 4
|
||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||
with open(out_path, "wb") as f:
|
||
f.write(pal)
|
||
|
||
|
||
# ---- 5. C-заголовок --------------------------------------------------
|
||
def write_header(out_path, env_pages):
|
||
"""Заголовок ОДИН на оба тайлсета: раскладка id -> (страница, idx) у них
|
||
общая (пакуются по одному объединению id), различаются только имена
|
||
файлов и палитра. Поэтому здесь два массива имён и один индекс."""
|
||
lines = [
|
||
"/* pop_bg_atlas.h — раскладка атласов статического фона PoP.",
|
||
" * Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.",
|
||
" *",
|
||
" * Прямая адресация (ноль remap-таблиц в W2):",
|
||
" * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK),
|
||
" * WALL id N -> atlas wall, idx N",
|
||
" * FORE id N -> atlas fore, idx N",
|
||
" *",
|
||
" * ДВА ТАЙЛСЕТА (порт tbl_envir_ki[tbl_level_type], seg000:1108):",
|
||
" * 0 = подземелье (pop_*), 1 = дворец (pal_*). Набор id и раскладка",
|
||
" * у них ОДНИ И ТЕ ЖЕ — меняются только файлы и 32 записи палитры",
|
||
" * (env 0x50..0x5F, wall 0x60..0x6F), см. *tile.pal.",
|
||
" */",
|
||
"#ifndef POP_BG_ATLAS_H",
|
||
"#define POP_BG_ATLAS_H",
|
||
"",
|
||
"#define POP_ENV_SHIFT %d" % ENV_SHIFT,
|
||
"#define POP_ENV_MASK %d" % ENV_MASK,
|
||
"#define POP_ENV_PAGES %d" % len(env_pages),
|
||
"#define POP_TILESETS 2",
|
||
"",
|
||
"/* Палитра: env-слоты, wall-слоты (спрайт-пиксель i -> база+i). */",
|
||
"#define POP_PAL_ENV 0x50",
|
||
"#define POP_PAL_WALL 0x60",
|
||
"",
|
||
"/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы",
|
||
" * видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия",
|
||
" * строк уедет в каждый включивший заголовок модуль. */",
|
||
"#ifdef POP_BG_ATLAS_NAMES",
|
||
"static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {",
|
||
]
|
||
for prefix, _ in SETS.values():
|
||
row = ", ".join(('"%s%s"' % (prefix, nm.split("_", 1)[1]) if nm else "0")
|
||
for nm in env_pages)
|
||
lines.append(" { %s }," % row)
|
||
lines += [
|
||
"};",
|
||
'static const char *const pop_wall_atl[POP_TILESETS] = { %s };'
|
||
% ", ".join('"%swall.atl"' % p for p, _ in SETS.values()),
|
||
'static const char *const pop_fore_atl[POP_TILESETS] = { %s };'
|
||
% ", ".join('"%sfore.atl"' % p for p, _ in SETS.values()),
|
||
'static const char *const pop_tile_pal[POP_TILESETS] = { %s };'
|
||
% ", ".join('"%stile.pal"' % p for p, _ in SETS.values()),
|
||
"#endif /* POP_BG_ATLAS_NAMES */",
|
||
'#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */',
|
||
"#define POP_PAL_POT 0x%02X" % POT_PAL_BASE,
|
||
"/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),",
|
||
" зелёный (перо/переворот) и синий (вред/открыть) = те же кадры",
|
||
" под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */"
|
||
% (POT_BUBBLE_GREEN_BASE, POT_BUBBLE_GREEN_BASE + len(POT_BUBBLE_IDS) - 1,
|
||
POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1),
|
||
"#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE,
|
||
"#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_BASE,
|
||
'#define POP_BG_PAL "pop_bg.pal"',
|
||
"",
|
||
"#endif",
|
||
"",
|
||
]
|
||
with open(out_path, "w") as f:
|
||
f.write("\n".join(lines))
|
||
|
||
|
||
def main():
|
||
global SRC_DIRS
|
||
set_name = sys.argv[1] if len(sys.argv) > 1 else "dungeon"
|
||
if set_name not in SETS:
|
||
die(f"набор '{set_name}': ожидался один из {', '.join(SETS)}")
|
||
prefix, dirs = SETS[set_name]
|
||
SRC_DIRS = dirs()
|
||
print(f"набор: {set_name} (источник {os.path.basename(SRC_DIRS[0])}, "
|
||
f"фолбэк {os.path.basename(SRC_DIRS[1])}), префикс {prefix}")
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
used = collect_used()
|
||
env_ids = sorted(set(id for ch, id in used if ch != R.CHTAB_WALL)
|
||
| CLIMB_OVERLAY_ENV_IDS # + пол-оверлей подъёма
|
||
| LOOSE_ANIM_ENV_IDS # + дрожащие кадры loose
|
||
| SPIKE_ANIM_ENV_IDS # + кадры выдвижения пик
|
||
| GATE_ANIM_ENV_IDS # + кадры открытия ворот
|
||
| GATE_TOP_ENV_IDS # + верх ворот (тайл сверху)
|
||
| {GATE_TOP_MASK_ID} # и его маска (mono)
|
||
| TORCH_ENV_IDS # + основание факела
|
||
| STUCK_ENV_IDS # + нажатая кнопка-closer
|
||
| CHOMPER_BOT_IDS # + чомпер: низ
|
||
| CHOMPER_TOP_IDS # верх
|
||
| CHOMPER_FORE_IDS # фронт (поверх Kid)
|
||
| CHOMPER_BLOOD_IDS # кровь (mono)
|
||
| CHOMPER_BLOOD_FORE_IDS
|
||
| OPENER_NOFLOOR_ENV_IDS # + opener без пола слева
|
||
| LEVELDOOR_ENV_IDS # + дверь уровня (створка/лестница)
|
||
| PALACE_ENV_IDS # + куски, что рисует только палас
|
||
| MIRROR_ENV_IDS) # + зеркало (ставится в рантайме)
|
||
wall_ids = sorted(id for ch, id in used if ch == R.CHTAB_WALL)
|
||
|
||
env_bg = [i for i in env_ids if i not in FORE_ENV_IDS]
|
||
env_fore = [i for i in env_ids if i in FORE_ENV_IDS]
|
||
|
||
# ENV фон -> страницы по id>>SHIFT
|
||
pages = {}
|
||
for id in env_bg:
|
||
w, h, pix = load_indexed(R.CHTAB_ENV, id, id in NO_TRANSP_ENV_IDS,
|
||
CHOMPER_BLOOD_COLOR if id in CHOMPER_BLOOD_IDS
|
||
else (POT_MASK_COLOR if id == GATE_TOP_MASK_ID
|
||
else None))
|
||
pages.setdefault(id >> ENV_SHIFT, {})[id & ENV_MASK] = (w, h, pix)
|
||
max_pg = max(pages) if pages else -1
|
||
env_page_names = []
|
||
total_bytes = 0
|
||
for pg in range(max_pg + 1):
|
||
if pg in pages:
|
||
nm = f"{prefix}env{pg}.atl"
|
||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, nm), pages[pg])
|
||
total_bytes += sz
|
||
env_page_names.append(nm)
|
||
print(f" {nm}: {len(pages[pg])} спрайтов, каталог {cnt}, {sz} Б")
|
||
else:
|
||
env_page_names.append(None)
|
||
print(f" (env стр {pg}: пусто)")
|
||
|
||
# WALL -> idx=id
|
||
wall_entries = {id: load_indexed(
|
||
R.CHTAB_WALL, id, False,
|
||
PALACE_WALL_MONO_COLOR
|
||
if (set_name == "palace" and id in PALACE_WALL_MONO_IDS)
|
||
else None)
|
||
for id in wall_ids}
|
||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "wall.atl"), wall_entries)
|
||
total_bytes += sz
|
||
print(f" {prefix}wall.atl: {len(wall_ids)} спрайтов, каталог {cnt}, {sz} Б")
|
||
|
||
# FORE -> idx=id
|
||
fore_entries = {id: load_indexed(R.CHTAB_ENV, id, False,
|
||
CHOMPER_BLOOD_COLOR
|
||
if id in CHOMPER_BLOOD_FORE_IDS else None)
|
||
for id in env_fore}
|
||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "fore.atl"), fore_entries)
|
||
total_bytes += sz
|
||
print(f" {prefix}fore.atl: {len(env_fore)} спрайтов, каталог {cnt}, {sz} Б")
|
||
|
||
# POTION (chtab_1) -> idx=id: склянки + кадры пузырька + маска
|
||
# chtab_1 целиком (res151..res173 = id 1..23): пламя факелов, меч-предмет,
|
||
# склянки зелий. Пузырьки и их маска рисуются MONO-блитом — пакуем
|
||
# силуэтами в нужном цвете (см. POT_*_COLOR).
|
||
pot_entries = {}
|
||
for id in range(1, 24):
|
||
if id in POT_BUBBLE_IDS:
|
||
pot_entries[id] = load_potion(id, POT_BUBBLE_COLOR)
|
||
elif id == POT_MASK_ID:
|
||
pot_entries[id] = load_potion(id, POT_MASK_COLOR)
|
||
elif id in POT_FLAME_IDS:
|
||
# Пламя оригинал рисует blitters_0_no_transp (seg008
|
||
# draw_tile_anim_right) — непрозрачным боксом поверх всего, что
|
||
# сзади. Все 9 кадров лежат на одном канвасе 16x18, поэтому
|
||
# непрозрачный блит сам стирает предыдущий кадр: heal не нужен.
|
||
pot_entries[id] = load_potion(id, opaque=True)
|
||
else:
|
||
pot_entries[id] = load_potion(id)
|
||
# Наборы пузырьков ДРУГИХ цветов: те же кадры другим mono-цветом.
|
||
# зелёный — типы 3/4 (перо, переворот), синий — типы 5/6 (вред, открыть).
|
||
for i, id in enumerate(POT_BUBBLE_IDS):
|
||
pot_entries[POT_BUBBLE_GREEN_BASE + i] = load_potion(id, POT_BUBBLE_GREEN_COLOR)
|
||
pot_entries[POT_BUBBLE_BLUE_BASE + i] = load_potion(id, POT_BUBBLE_BLUE_COLOR)
|
||
# chtab_1 (пламя/меч/зелья) от тайлсета НЕ зависит — пакуем один раз.
|
||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, "pop_pot.atl"), pot_entries)
|
||
total_bytes += sz
|
||
print(f" pop_pot.atl (chtab_1: факелы+зелья): {len(pot_entries)} спрайтов, каталог {cnt}, {sz} Б")
|
||
|
||
build_tileset_palette(os.path.join(OUT_DIR, prefix + "tile.pal"))
|
||
print(f" {prefix}tile.pal: 32 записи (env 0x50..0x5F + wall 0x60..0x6F)")
|
||
build_palette(os.path.join(OUT_DIR, "pop_bg.pal"))
|
||
write_header(os.path.join(OUT_DIR, "pop_bg_atlas.h"), env_page_names)
|
||
|
||
n_pages = sum(1 for n in env_page_names if n) + 2
|
||
print(f"ИТОГО: {len(env_bg)} env-фон + {len(wall_ids)} wall + "
|
||
f"{len(env_fore)} fore = {len(env_bg)+len(wall_ids)+len(env_fore)} спрайтов")
|
||
print(f" {n_pages} EMM-страниц, {total_bytes} Б на диске "
|
||
f"(+1КБ палитра), W2: {n_pages}×atlas_t = {n_pages*3} Б хэндлов")
|
||
print(f" -> {OUT_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|