Files
Sprinter-SDCC/toolchain/pop_pack_title.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

127 lines
5.1 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
"""pop_pack_title.py — титульные композиции SDLPoP для Sprinter.
Исходник — PNG-ресурсы ``data/TITLE/res51..55`` из локального SDLPoP.
Runtime не должен держать 320x200 картинку в W2 и не разбирает PNG: каждая
готовая композиция разрезана на пять getimage-лент высотой не более 49
строк. Лента вместе с заголовком атласа занимает одну EMM-страницу; экран
рисуется лента за лентой, и страница сразу освобождается.
Композиции в точности повторяют ``show_title()`` до story/intro:
t0 — основной рисунок;
t1 — рисунок + PRESENTS;
t2 — рисунок + название игры;
t3 — рисунок + логотип PRINCE OF PERSIA и Jordan Mechner.
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "applications" / "PoP" / "SDLPoP" / "data" / "TITLE"
OUT = ROOT / "applications" / "PoP" / "poc" / "res" / "title"
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
ATL_MIN_SIZE = 0x100
PAGE_SIZE = 16384
STRIP_H = 49
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")
# Каталог хранит preview-габарит в uint8, но atlas_image читает только
# offset; реальный getimage-width — u16 в блобе. Так уже пакуются
# широкие 320-пиксельные полосы фона.
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"title atlas {len(out)} B > {PAGE_SIZE} B")
return bytes(out)
def image(n: int) -> Image.Image:
im = Image.open(SRC / f"res{n}.png")
if im.mode != "P":
raise SystemExit(f"res{n}.png: expected indexed PNG, got {im.mode}")
return im.copy()
def assert_palette(base: Image.Image, overlay: Image.Image, n: int) -> None:
if base.getpalette()[:48] != overlay.getpalette()[:48]:
raise SystemExit(f"res{n}.png uses a palette different from res51.png")
def paste_opaque(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
assert_palette(dst, src, n)
dst.paste(src, (x, y))
def paste_transparent(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
assert_palette(dst, src, n)
# TITLE_POP — единственный из пяти элементов, который SDLPoP рисует
# blitters_10h_transp. В PNG тот же факт записан colour-key index 0.
# Image.point() у P-изображения возвращает снова P, а paste() принимает
# маску только L/1/RGBA. Строим L явно, не меняя индексы самой картинки.
mask = Image.frombytes("L", src.size,
bytes(0 if p == 0 else 255 for p in src.tobytes()))
dst.paste(src, (x, y), mask)
def write_palette(base: Image.Image) -> None:
rgb = base.getpalette()[:48]
pal = bytearray()
for i in range(16):
r, g, b = rgb[i * 3:i * 3 + 3]
pal += bytes((b, g, r, 0)) # родной формат gfx_pal_fload
(OUT / "title.pal").write_bytes(pal)
def write_scene(scene: int, canvas: Image.Image) -> int:
count = 0
for part, top in enumerate(range(0, canvas.height, STRIP_H)):
band = canvas.crop((0, top, canvas.width,
min(top + STRIP_H, canvas.height)))
path = OUT / f"t{scene}_{part}.atl"
path.write_bytes(atlas_blob(band.tobytes(), band.width, band.height))
count += 1
return count
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
main_image = image(51)
if main_image.size != (320, 200):
raise SystemExit(f"res51.png: expected 320x200, got {main_image.size}")
presents = main_image.copy()
paste_opaque(presents, image(52), 96, 106, 52)
game = main_image.copy()
paste_opaque(game, image(53), 96, 122, 53)
logo = main_image.copy()
paste_transparent(logo, image(54), 24, 107, 54)
paste_opaque(logo, image(55), 48, 184, 55)
write_palette(main_image)
scenes = (main_image, presents, game, logo)
strips = [write_scene(i, scene) for i, scene in enumerate(scenes)]
assert strips == [5, 5, 5, 5]
print(f"{OUT}: {len(scenes)} title compositions x {strips[0]} strips, "
f"one strip <= {STRIP_H} rows / one EMM page")
if __name__ == "__main__":
main()