Files
Sprinter-SDCC/toolchain/pop_pack_intro.py
T
snark13 47a4b084c4 Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры
Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
  снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
  (UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
  равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
  ABOUT.  About показывает тот же текст, что стартовый Sprinter screen,
  минус строка про клавиши — общий about_text(), чтобы экраны не
  разъехались.  CONTROLS собирается таблицей и центрируется по
  фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
  (SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
  а не пункт меню.  POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
  модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах.  Нижняя:
  порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
  24 тика).  Верхняя отладочная переведена с палочек на текст
  «Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
  своим форматированием чисел (без printf и без деления).
  Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
  не растёт, весь рендер в банке.  Бюджет после правок не изменился
  (_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
  всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
  не блитятся вовсе, а вход в комнату заливает только игровое поле
  (pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
  ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
  статус-строке: геометрия, семантика text_time_total как идентификатора
  сообщения, мигание, рестарт по истечении 36/288.

Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:58:36 +03:00

314 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Упаковка story/PV intro SDLPoP в полосы SPA1 для Sprinter.
Исходники остаются единственным источником истины: TITLE/res41..45, res51 и
PV/res800/850/950/980. Runtime получает только готовые 320x200 композиции
и 256-цветные палитры. Это намеренно переносит смешивание палитров Princess,
Jaffar, спальни и кровати из ограниченного Z80 в build-time.
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
TITLE = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "TITLE"
PV = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "PV"
PRINCE = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "PRINCE"
KID = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "KID"
OUT = ROOT / "applications" / "PoP" / "poc" / "res" / "pv"
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
ATL_MIN_SIZE = 0x100
PAGE_SIZE = 16384
STRIP_H = 49
def transpose_cols(width: int, height: int, rowmajor: bytes) -> bytes:
"""Row-major -> column-major для бесплатного horizontal flip в BGI."""
out = bytearray(width * height)
for column in range(width):
base = column * height
for row in range(height):
out[base + row] = rowmajor[row * width + column]
return bytes(out)
def atlas_blob(pixels: bytes, width: int, height: int) -> bytes:
"""Один непрозрачный getimage в минимальном SPA1-атласе."""
data_off = ATL_DIR_OFF + 8
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = 1
out[ATL_DIR_OFF:ATL_DIR_OFF + 2] = data_off.to_bytes(2, "little")
out[ATL_DIR_OFF + 2] = min(width, 255)
out[ATL_DIR_OFF + 3] = height
out[ATL_DIR_OFF + 4] = out[ATL_DIR_OFF + 5] = 1
out += width.to_bytes(2, "little") + height.to_bytes(2, "little")
out += pixels
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"intro atlas {len(out)} B > {PAGE_SIZE} B")
return bytes(out)
def sprite_atlas(path: Path, entries: dict[int, tuple[int, int, bytes]]) -> None:
"""Атлас небольших прозрачных кадров, целиком живущий в одной EMM.
В отличие от atlas_blob() для полноэкранной полосы, здесь нужен каталог
нескольких картинок: runtime держит страницу открытой всю PV-сцену и
переключает только idx. Пустые индексы не требуются — все наборы ниже
нумеруются подряд с нуля.
"""
count = len(entries)
if not count or count > 255:
raise SystemExit(f"{path}: bad sprite atlas count {count}")
data_off = ATL_DIR_OFF + count * 8
directory = bytearray(count * 8)
blobs = bytearray()
for idx in range(count):
width, height, pixels = entries[idx]
offset = data_off + len(blobs)
blobs += width.to_bytes(2, "little") + height.to_bytes(2, "little")
blobs += pixels
d = idx * 8
directory[d:d + 2] = offset.to_bytes(2, "little")
directory[d + 2] = width
directory[d + 3] = height
directory[d + 4] = directory[d + 5] = 1
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = count
out[ATL_DIR_OFF:ATL_DIR_OFF + len(directory)] = directory
out += blobs
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"{path}: {len(out)} B > one EMM page")
path.write_bytes(out)
def sprite_frames(directory: Path, first: int, last: int,
color_base: int, column_major: bool = False
) -> dict[int, tuple[int, int, bytes]]:
"""PNG-кадры -> getimage. Ноль PNG становится прозрачным 0xFF BGI."""
result = {}
for index, number in enumerate(range(first, last + 1)):
image = png(directory, number)
pixels = bytes(0xFF if pixel == 0 else color_base + pixel
for pixel in image.tobytes())
if column_major:
pixels = transpose_cols(image.width, image.height, pixels)
result[index] = image.width, image.height, pixels
return result
def png(directory: Path, number: int) -> Image.Image:
image = Image.open(directory / f"res{number}.png")
if image.mode != "P":
raise SystemExit(f"res{number}.png: expected indexed PNG, got {image.mode}")
return image.copy()
def pal_file(path: Path) -> list[tuple[int, int, int]]:
"""16 цветов из .pal ресурса (4 байта заголовка, дальше RGB по 6 бит)."""
raw = path.read_bytes()
return [(raw[4 + i * 3] << 2, raw[5 + i * 3] << 2, raw[6 + i * 3] << 2)
for i in range(16)]
def palette(image: Image.Image) -> list[tuple[int, int, int]]:
raw = image.getpalette()
return [tuple(raw[index * 3:index * 3 + 3]) for index in range(16)]
def write_palette(path: Path, entries: list[tuple[int, int, int]]) -> None:
"""B,G,R,0 — ровно формат gfx_pal_fload; незадействованное чёрное."""
out = bytearray()
entries = (entries + [(0, 0, 0)] * 256)[:256]
for red, green, blue in entries:
out += bytes((blue, green, red, 0))
path.write_bytes(out)
def write_scene(prefix: str, number: int, canvas: Image.Image) -> None:
if canvas.size != (320, 200):
raise SystemExit(f"{prefix}{number}: expected 320x200, got {canvas.size}")
for part, top in enumerate(range(0, 200, STRIP_H)):
band = canvas.crop((0, top, 320, min(top + STRIP_H, 200)))
(OUT / f"{prefix}{number}_{part}.atl").write_bytes(
atlas_blob(band.tobytes(), band.width, band.height))
def story_scenes() -> list[Image.Image]:
"""show_title/end_sequence в единой с title палитре.
res41 кодирует тёмный фон индексом 14, а его палитра делает этот индекс
чёрным; show_title() SDLPoP подменяет его #100060. Тексты res42..45
имеют индекс 1, но этот индекс у res41 тоже тёмный. Ремап на индексы
title/res51 (9 = тёмно-синий, 15 = белый) позволяет проявлять историю
прямо поверх последнего title-экрана без смены палитры.
"""
frame_raw = bytearray(png(TITLE, 41).tobytes())
for offset, pixel in enumerate(frame_raw):
if pixel == 14:
frame_raw[offset] = 9
frame = Image.frombytes("P", (320, 200), bytes(frame_raw))
result = []
for text_number, ypos in ((42, 25), (43, 25), (45, 26), (44, 25)):
canvas = frame.copy()
raw = bytearray(canvas.tobytes())
text = png(TITLE, text_number)
for sy in range(text.height):
for sx in range(text.width):
if text.getpixel((sx, sy)):
raw[(ypos + sy) * 320 + 24 + sx] = 15
canvas.frombytes(bytes(raw))
result.append(canvas)
result.append(frame)
return result
def pv_palette_canvas() -> tuple[Image.Image, list[tuple[int, int, int]]]:
"""Спальня + bed. Номера 0..15=room, 16..31=Princess,
32..47=Jaffar, 48..63=bed. Благодаря этому у смешанных PNG не теряются
исходные цвета при одном gfx_pal_fload() на весь PV."""
room = png(PV, 951)
bed = png(PV, 981)
raw = bytearray(room.tobytes())
bed_raw = bed.tobytes()
for sy in range(bed.height):
for sx in range(bed.width):
pixel = bed_raw[sy * bed.width + sx]
if pixel:
raw[(142 + sy) * 320 + sx] = 48 + pixel
canvas = Image.frombytes("P", room.size, bytes(raw))
# 80..95 — пламя факелов. Это отдельный chtab PRINCE в оригинале; без
# отдельной строки цвета динамические кадры становились бы цветами стены.
# 112..127 (0x70) — палитра КИДА: на PV-экране финала и сцен 8/9 он и
# мышь рисуются СВОИМИ атласами (chtab_2), а их индексы запечены под
# игровые слоты 0x70. Без этой строки они выходили бы чёрными.
return (canvas, palette(room) + palette(png(PV, 801)) +
palette(png(PV, 851)) + palette(bed) + palette(png(PV, 901)) +
palette(png(PRINCE, 151)) + [(0, 0, 0)] * 16 +
pal_file(KID / "res400.pal"))
def sprite_paste(dst: Image.Image, sprite: Image.Image, x: int, floor: int,
color_base: int) -> None:
"""Положить спрайт ногами на floor, remap его палитру в отдельную строку."""
raw = bytearray(dst.tobytes())
src = sprite.tobytes()
y = floor - sprite.height
for sy in range(sprite.height):
dy = y + sy
if dy < 0 or dy >= 200:
continue
for sx in range(sprite.width):
dx = x + sx
pixel = src[sy * sprite.width + sx]
if pixel and 0 <= dx < 320:
raw[dy * 320 + dx] = color_base + pixel
dst.frombytes(bytes(raw))
def pv_scenes() -> tuple[list[Image.Image], list[tuple[int, int, int]]]:
base, colors = pv_palette_canvas()
# Позиции Char из init_princess()/init_vizier(): x=120/198, y=166.
# В оригинале точный offset задаёт frame table; статические композиции
# выравнивают персонажей по полу y=166, не хранят таблицы PV-анимации.
waiting = base.copy()
sprite_paste(waiting, png(PV, 801), 110, 166, 16)
jaffar = waiting.copy()
sprite_paste(jaffar, png(PV, 851), 188, 166, 32)
magic = base.copy()
sprite_paste(magic, png(PV, 805), 110, 166, 16)
sprite_paste(magic, png(PV, 870), 188, 166, 32)
# res953 — первый полный кадр песочных часов; он использует палитру room.
sprite_paste(magic, png(PV, 953), 18, 166, 0)
alone = base.copy()
sprite_paste(alone, png(PV, 816), 110, 166, 16)
sprite_paste(alone, png(PV, 959), 18, 166, 0)
# FG9: статические ключевые позы тех же scene 2/4/6/8/9/12. PV2
# использует второй набор Princess (res900), поэтому он лежит в строке
# палитры 64..79. Мышь в оригинале меняет только короткую позу рядом с
# Princess; здесь соответствующая стадия выделена отдельным кадром.
stand = base.copy()
sprite_paste(stand, png(PV, 801), 134, 166, 16)
lying = base.copy()
sprite_paste(lying, png(PV, 901), 78, 162, 64)
# Часы для cutscene_4 выбираются runtime по оставшимся минутам и
# анимируются вместе с песком; в статической основе их быть не должно.
mouse = base.copy()
sprite_paste(mouse, png(PV, 920), 120, 169, 64)
sprite_paste(mouse, png(PV, 959), 18, 166, 0)
short_time = base.copy()
sprite_paste(short_time, png(PV, 816), 146, 166, 16)
sprite_paste(short_time, png(PV, 953), 18, 166, 0)
return [waiting, jaffar, magic, alone, stand, lying, mouse, short_time], colors
def pv_animation_assets() -> None:
"""Ресурсы покадрового intro.
`b0_*` — единственный чистый фон. Он копируется accelerator-ом между
обеими экранными страницами; спрайты рисуются банком без shadow, так что
следующий кадр автоматически начинает с фона. Каждый actor-атлас <=16K,
поэтому нет чтения 320x200 с HDD в анимационном цикле.
"""
base, _ = pv_palette_canvas()
write_scene("b", 0, base)
# frame_tbl_cuts из SDLPoP адресует Princess как image 0..16 ресурса
# 800 (то есть PNG res801..817). Для Jaffar PV1 адресуется ресурс 850:
# image 0..37 = res851..888. Все 38 кадров не помещаются в одну EMM,
# поэтому сохраняем их тремя последовательными атласами. Runtime знает
# границы, но номер кадра остаётся исходным image-id 0..37.
sprite_atlas(OUT / "a0.atl", sprite_frames(PV, 801, 817, 16, True))
sprite_atlas(OUT / "j0.atl", sprite_frames(PV, 851, 863, 32, True))
sprite_atlas(OUT / "j1.atl", sprite_frames(PV, 864, 874, 32, True))
sprite_atlas(OUT / "j2.atl", sprite_frames(PV, 875, 888, 32, True))
# ВТОРОЙ набор Princess (PV2, res901..930 = image 0..29 ресурса 900).
# Он нужен сценам 8/9 (сидит и гладит мышь, встаёт, приседает) и финалу
# (поворачивается и обнимает Кида) — поз PV1 для этого не хватает.
# 26.7 КБ не влезают в одну EMM, поэтому два атласа; номер кадра при
# этом остаётся исходным image-id 0..29, границу знает runtime.
# Палитровая строка 64..79 — та же, что у PV2 в статических сценах.
sprite_atlas(OUT / "a1.atl", sprite_frames(PV, 901, 917, 64, True))
sprite_atlas(OUT / "a2.atl", sprite_frames(PV, 918, 930, 64, True))
# res953/res954 — состояния часов, res960..962 — три фазы песка.
sprite_atlas(OUT / "h0.atl", sprite_frames(PV, 952, 962, 0))
sprite_atlas(OUT / "t0.atl", sprite_frames(PRINCE, 151, 159, 80))
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
stories = story_scenes()
write_palette(OUT / "story.pal", palette(png(TITLE, 51)))
for number, scene in enumerate(stories):
write_scene("s", number, scene)
scenes, colors = pv_scenes()
write_palette(OUT / "pv.pal", colors)
for number, scene in enumerate(scenes):
write_scene("p", number, scene)
pv_animation_assets()
print(f"{OUT}: 5 story + 8 PV compositions + base x 5 strips, "
f"6 animated actor atlases, one strip/page <= {STRIP_H} rows")
if __name__ == "__main__":
main()