Меню, статус-строка и оболочка игры: 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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_extract_font.py — SDLPoP-шрифты в один Sprinter .atl-ресурс.
|
||||
|
||||
Источник истины — оригинальные встроенные шрифты SDLPoP:
|
||||
* hc_small_font_data[] из menu.c — пункты меню;
|
||||
* hc_font_data[] из seg009.c — крупные сообщения/заголовки.
|
||||
|
||||
В исходнике глиф — IMAGE_DATA(height, width, flags) и 1-битные строки.
|
||||
Здесь его силуэт переводится в getimage (0xFF = прозрачность) и пакуется в
|
||||
FONT\\FONT.ATL. Это именно шрифтовой атлас, а не заранее нарисованные
|
||||
строки: номер ленты = код ASCII, поэтому runtime сохраняет произвольный
|
||||
текст и пропорциональную ширину оригинала.
|
||||
|
||||
Хвост страницы [0x3C00..0x3FFF] намеренно оставлен свободным: pop_menu.c
|
||||
кладёт туда снимок игровой палитры на время паузы. Тогда 1 КБ не занимает
|
||||
дефицитную W2.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_SMALL = ROOT / "applications" / "PoP" / "SDLPoP" / "src" / "menu.c"
|
||||
SRC_BIG = ROOT / "applications" / "PoP" / "SDLPoP" / "src" / "seg009.c"
|
||||
OUT_DIR = ROOT / "applications" / "PoP" / "poc" / "res" / "font"
|
||||
OUT_ATL = OUT_DIR / "font.atl"
|
||||
OUT_H = ROOT / "applications" / "PoP" / "roomtest" / "pop_font.h"
|
||||
|
||||
FIRST, LAST = 32, 126
|
||||
FONT_COLOR = 0x3F
|
||||
ATL_MAGIC = b"SPA1"
|
||||
ATL_DIR_OFF = 0x68
|
||||
SNAPSHOT_OFF = 0x3C00
|
||||
PAGE_SIZE = 16384
|
||||
|
||||
|
||||
def extract_array(path: Path, name: str) -> list[int]:
|
||||
text = path.read_text()
|
||||
match = re.search(rf"byte {name}\[\] = \{{(.*?)\n\}};", text, re.S)
|
||||
assert match, f"{name} not found in {path}"
|
||||
body = re.sub(r"//[^\n]*", "", match.group(1))
|
||||
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
|
||||
|
||||
def word(mm: re.Match) -> str:
|
||||
value = int(mm.group(1), 0)
|
||||
return f"{value & 0xFF}, {(value >> 8) & 0xFF}"
|
||||
|
||||
def bin4(mm: re.Match) -> str:
|
||||
b7, b6, b5, b4 = (int(v, 0) if v != "_" else 0 for v in mm.groups())
|
||||
return str((b4 << 4) | (b5 << 5) | (b6 << 6) | (b7 << 7))
|
||||
|
||||
def bin8(mm: re.Match) -> str:
|
||||
bits = [int(v, 0) if v != "_" else 0 for v in mm.groups()]
|
||||
return str(sum(bit << (7 - i) for i, bit in enumerate(bits)))
|
||||
|
||||
def image_data(mm: re.Match) -> str:
|
||||
h, w, flags = (int(v, 0) for v in mm.groups())
|
||||
return (f"{h & 0xFF}, {(h >> 8) & 0xFF}, "
|
||||
f"{w & 0xFF}, {(w >> 8) & 0xFF}, "
|
||||
f"{flags & 0xFF}, {(flags >> 8) & 0xFF}")
|
||||
|
||||
body = re.sub(r"\bWORD\s*\(\s*(\w+)\s*\)", word, body)
|
||||
body = re.sub(r"\bIMAGE_DATA\s*\(\s*(\w+)\s*,\s*(\w+)\s*,\s*(\w+)\s*\)",
|
||||
image_data, body)
|
||||
body = re.sub(r"\bBINARY_4\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin4, body)
|
||||
body = re.sub(r"\bBINARY_8\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin8, body)
|
||||
body = re.sub(r"\b_\b", "0", body)
|
||||
# Hex обязан идти первым: иначе regexp съедает в `0x20` только `0`.
|
||||
return [int(v, 0) for v in re.findall(r"0[xX][0-9a-fA-F]+|\d+", body)]
|
||||
|
||||
|
||||
def parse_glyphs(vals: list[int]) -> tuple[dict, list[dict]]:
|
||||
first, last = vals[0], vals[1]
|
||||
font = {
|
||||
"first": first,
|
||||
"last": last,
|
||||
"ascent": vals[2] | (vals[3] << 8),
|
||||
"below": vals[4] | (vals[5] << 8),
|
||||
"space_lines": vals[6] | (vals[7] << 8),
|
||||
"space_chars": vals[8] | (vals[9] << 8),
|
||||
}
|
||||
pos = 10 + (last - first + 1) * 2 # offsets у SDLPoP вычисляются в runtime
|
||||
glyphs = []
|
||||
while pos < len(vals):
|
||||
h = vals[pos] | (vals[pos + 1] << 8)
|
||||
w = vals[pos + 2] | (vals[pos + 3] << 8)
|
||||
pos += 6 # height, width, flags
|
||||
stride = (w + 7) // 8
|
||||
rows = []
|
||||
for _ in range(h):
|
||||
rows.append(vals[pos:pos + stride])
|
||||
pos += stride
|
||||
glyphs.append({"h": h, "w": w, "rows": rows})
|
||||
assert len(glyphs) == last - first + 1, f"glyph count {len(glyphs)}"
|
||||
return font, glyphs
|
||||
|
||||
|
||||
def glyph_pixels(glyph: dict) -> bytes:
|
||||
"""1 бит/пиксель SDLPoP -> 8bpp getimage: 1 = FONT_COLOR."""
|
||||
out = bytearray()
|
||||
for row in glyph["rows"]:
|
||||
for x in range(glyph["w"]):
|
||||
byte = row[x // 8]
|
||||
on = byte & (1 << (7 - (x & 7)))
|
||||
out.append(FONT_COLOR if on else 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def pack_atlas(fonts: list[tuple[dict, list[dict]]]) -> int:
|
||||
entries = []
|
||||
for font, glyphs in fonts:
|
||||
assert font["first"] <= FIRST <= LAST <= font["last"]
|
||||
entries.extend(glyphs[FIRST - font["first"]:LAST - font["first"] + 1])
|
||||
assert len(entries) <= 255
|
||||
|
||||
data_off = ATL_DIR_OFF + len(entries) * 8
|
||||
out = bytearray(data_off)
|
||||
out[0:4] = ATL_MAGIC
|
||||
out[4] = len(entries)
|
||||
for index, glyph in enumerate(entries):
|
||||
offset = len(out)
|
||||
pixels = glyph_pixels(glyph)
|
||||
out += glyph["w"].to_bytes(2, "little")
|
||||
out += glyph["h"].to_bytes(2, "little")
|
||||
out += pixels
|
||||
directory = ATL_DIR_OFF + index * 8
|
||||
out[directory:directory + 2] = offset.to_bytes(2, "little")
|
||||
out[directory + 2] = glyph["w"]
|
||||
out[directory + 3] = glyph["h"]
|
||||
out[directory + 4] = 1
|
||||
out[directory + 5] = 1
|
||||
|
||||
if len(out) > SNAPSHOT_OFF:
|
||||
raise SystemExit(f"font atlas: {len(out)} Б, нет места для palette snapshot @ {SNAPSHOT_OFF:#x}")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUT_ATL.write_bytes(out)
|
||||
return len(out)
|
||||
|
||||
|
||||
def emit_header(small: dict, big: dict, atlas_size: int) -> None:
|
||||
small_n = LAST - FIRST + 1
|
||||
text = f"""/*
|
||||
* pop_font.h — контракт FONT\\FONT.ATL, СГЕНЕРИРОВАН.
|
||||
* Источник: SDLPoP hc_small_font_data[] и hc_font_data[].
|
||||
* Генератор: toolchain/pop_extract_font.py. Руками не править.
|
||||
*/
|
||||
#ifndef POP_FONT_H
|
||||
#define POP_FONT_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define POP_FONT_COLOR 0x{FONT_COLOR:02X}
|
||||
#define POP_FONT_FIRST {FIRST}
|
||||
#define POP_FONT_LAST {LAST}
|
||||
#define POP_FONT_SMALL_BASE 0
|
||||
#define POP_FONT_SMALL_ASCENT {small['ascent']}
|
||||
#define POP_FONT_SMALL_SPACE {small['space_chars']}
|
||||
#define POP_FONT_BIG_BASE {small_n}
|
||||
#define POP_FONT_BIG_ASCENT {big['ascent']}
|
||||
#define POP_FONT_BIG_SPACE {big['space_chars']}
|
||||
#define POP_FONT_SNAPSHOT_OFF 0x{SNAPSHOT_OFF:04X}
|
||||
#define POP_FONT_ATLAS_SIZE {atlas_size}
|
||||
|
||||
#endif
|
||||
"""
|
||||
OUT_H.write_text(text)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parsed = []
|
||||
for path, name in ((SRC_SMALL, "hc_small_font_data"), (SRC_BIG, "hc_font_data")):
|
||||
font, glyphs = parse_glyphs(extract_array(path, name))
|
||||
print(f"{name}: chars={font['first']}..{font['last']} ascent={font['ascent']} "
|
||||
f"space={font['space_chars']}")
|
||||
parsed.append((font, glyphs))
|
||||
atlas_size = pack_atlas(parsed)
|
||||
emit_header(parsed[0][0], parsed[1][0], atlas_size)
|
||||
print(f"{OUT_ATL}: 2 x {LAST - FIRST + 1} glyphs, {atlas_size} Б; "
|
||||
f"snapshot from 0x{SNAPSHOT_OFF:04X}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user