#!/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->позиция. Выход: в build/atl/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 (экстрактор + каскад загрузки) import sys as _sys, os as _os _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) import paths as P # раскладка каталогов SprPoP (tools/paths.py) OUT_DIR = str(P.atl("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 # ЧЁРНЫЙ ФОН АНИМАЦИЙ — ИМЕННО ЦВЕТ 0, а не «чёрный из палитры набора». # Вспышки (молния, урон, зелья) подменяют НУЛЕВУЮ запись палитры, и всё, # что закрашено 0x30/0x40, остаётся чёрным островом посреди вспышки — # пламя факелов и склянки «не мигали» (поймано пользователем 2026-08-25). POT_MASK_COLOR = 0 # маска 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: # Пиксель 0 — ЦВЕТ 0 (общий фон игрового поля), остальные — палитра # chtab_1. Ноль здесь принципиален: непрозрачный бокс пламени иначе # закрашивал бы фон «своим» чёрным (0x40) и не участвовал во вспышке. out = bytes((0 if v == 0 else 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.", " * Сгенерировано tools/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", "", "/* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена", " * видит только модуль загрузки, чтобы не размножать строки. */", "#ifdef POP_BG_ATLAS_NAMES", 'static const char *const pop_tile_pal[POP_TILESETS] = { %s };' % ", ".join('"%stile.pal"' % p for p, _ in SETS.values()), "#endif /* POP_BG_ATLAS_NAMES */", "/* Пузырёк зелья: красный набор = 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, "", "#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(str(P.GEN / "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()