Меню, статус-строка и оболочка игры: 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:
2026-08-25 13:58:36 +03:00
parent 7fd7f28ffc
commit 47a4b084c4
205 changed files with 8446 additions and 418 deletions
+185
View File
@@ -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()