Files
Sprinter-SDCC/applications/SprPoP/docs/levels_plan.md
T
snark13 31b82661eb SprPoP: автономное приложение, выделенное из roomtest
Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:12:28 +03:00

210 lines
16 KiB
Markdown
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.
# План: от одного уровня к нескольким (загрузка, переходы, тайлсеты)
Статус: план, 2026-08-01. Продолжает `PORT_PLAN.md` §7 (Фаза 1: «переходы
между экранами» → теперь между УРОВНЯМИ). Текущая точка: `SprPoP` играет
уровень 1 целиком в одной комнате-за-комнатой модели, но уровень нельзя
ни выбрать, ни закончить.
Источник истины — `../SDLPoP/src/` (правило `../CLAUDE.md`). Ключевые
места: `seg000.c: load_lev_spr/play_level_2/init_game`, `seg005.c:
up_pressed/go_up_leveldoor`, `seg006.c: play_seq → SEQ_END_LEVEL`,
`seg002.c` (спецсобытия уровней), `data.h:835..850` (потабличные различия
уровней).
---
## 0. Что уже готово (не проектировать заново)
- **Формат и загрузчик уровня.** `pop_level.c/.h` читает сырой
`res200N.bin` (2305 Б) в отдельную EMM-страницу; путь — параметр
`pop_level_load(const char *)`. Мультиуровневость здесь стоит одной
функции формирования имени.
- **Стартовая позиция уровня** уже разобрана: `pop_level_start_room()`,
`pop_level_start_pos()`, `pop_level_start_dir()` — реализованы и пока
НЕ вызываются (см. `../PoP/SprPoP/TASKS_CLOSED.md` L1-START).
- **Страж по данным уровня**: `pop_level_guard()` (порт `enter_guard`),
сохранение состояния между комнатами (`pop_guard_state_save`).
- **Палитра разложена по слотам ровно как в оригинале** (`pop_pack_kid.py`
`build_palette`): env 0x50, wall 0x60, pot 0x40, kid 0x70, меч 0x80,
страж 0x90. Это тот же раскрой, что `set_pal_arr(0x50/0x60)` в
`seg000.c:1140..1148`, — значит смена тайлсета не требует переиндексации
спрайтов Кида (см. §3).
- **Все 16 файлов уровней распакованы**: `../SDLPoP/data/LEVELS/res2000..
res2015.bin` (0 — демо-уровень).
---
## 1. Что реально различается между уровнями (замер по данным, не по памяти)
Таблицы из `../SDLPoP/src/data.h:840..847` + инвентарь тайлов, снятый прямо
с `res200N.bin` (маска `fg & 0x1F`):
| Ур. | Тайлсет | Страж | Новое против предыдущих |
|-----|---------|-------|--------------------------|
| 1 | dungeon | обычный | — (текущая база) |
| **2** | **dungeon** | **обычный** | **ничего нового: тот же набор объектов минус меч** |
| 3 | dungeon | СКЕЛЕТ | чомперы |
| 4 | palace | обычный | **тайлсет palace**, зеркало (спецсобытие `mirror_level`) — **СДЕЛАНО** |
| 5 | palace | обычный | новых ТАЙЛОВ нет; спецсобытие **тень крадёт зелье** (комната 24) — [L5-SHADOW](TASKS_OPEN.md#l5-shadow) |
| 6 | palace | ТОЛСТЫЙ | падение на входе (спецсобытие) |
| 7 | dungeon | обычный | — |
| 8, 9 | dungeon | обычный | — |
| 10, 11 | palace | обычный | — |
| 12 | dungeon | ТЕНЬ | seamless-выход (комната 23), исчезающий меч |
| 13 | dungeon | ВИЗИРЬ | мышь, особый выход |
| 14 | palace | нет | — |
| 15 | dungeon | нет | финал |
Прямое следствие для порядка работ: **уровень 2 не требует ни одного нового
ассета и ни одной новой механики** — он проверяет ровно машинерию перехода.
Это и есть первый шаг.
Прочие потабличные различия, которые придётся завести массивами по 16:
`tbl_level_type` (тайлсет), `tbl_guard_type` (−1 = стражей нет),
`tbl_guard_hp`, `tbl_level_color` (вариантные палитры, 1.3), `tbl_entry_pose`.
---
## 2. Шаг 1 — машинерия перехода (цель: уровни 1 → 2 → 3) — **СДЕЛАН 2026-08-04**
> **Итог.** Всё в этом разделе портировано и проверено в MAME: уровень 1 →
> Shift+L → уровень 2 (комната 5, дверь захлопывается за спиной, большие
> колонны рисуются) → уровень 3. Разбор что именно сделано и что по
> уровню 2 осталось — `../PoP/SprPoP/TASKS_CLOSED.md`, запись **L2**.
>
> Сверх плана пришлось доделать две вещи, без которых уровень 2 не играется:
> **`find_start_level_door`** (стартовый тайл уровня 2 — это правая половина
> двери уровня) и **большую склянку** `add_life` (тип зелья 2, комната 20).
Порядок именно такой; каждый пункт проверяем в MAME отдельно.
**2.1 Выход с уровня.** Портировать `up_pressed()` ветку двери
(`seg005.c:410..423`) + `go_up_leveldoor()` (`seg005.c:497`): дверь рядом
(при/за/перед персонажем) И `drawn_room != level.start_room` И створка
открыта полностью (`curr_room_modif >= 42` — вариант `fix_exit_door`) →
`Char.x = x_bump[...] + 10`, направление влево, `seq_70_go_up_on_level_door`.
Затем оживить опкод `0xF1 END_LEVEL` в `play_seq` (`../src/pop_kid.c:418`
— сейчас пустой `break`): `++pop_next_level`, как `seg006.c:662`.
**2.2 Цикл уровня.** В `main()` после тика: `if (pop_next_level !=
pop_current_level) → load_level(pop_next_level)`. Порядок сноса/подъёма
состояния (порт `load_lev_spr` + `play_level_2`):
`pop_level_free` → `pop_level_load("LEVELS\\res200%d.bin")` →
`pop_trob_reset` → `pop_guard_reset` → сброс tile-override'ов
(`ovr_*` в `sprpop.c`) → `enter_room(pop_level_start_room())` →
`kid_init(поза/позиция/направление из данных уровня)`.
**HP через уровень переносится** (в оригинале `hitp_beg_lev`), не сбрасывать
в максимум — сверить с `seg000.c` `init_game`/`play_level_2`.
**2.3 Стражи по уровню.** Завести `tbl_guard_type[16]`/`tbl_guard_hp[16]`;
`-1` = стражей на уровне нет (уровни 14, 15) — `pop_guard_enter` обязан это
понимать, иначе на 14-м полезут стражи из мусора. Для шага 1 (уровни 2, 3)
достаточно обычного стража, но проверку `-1` заложить сразу.
**2.4 Чит «следующий уровень» (Shift+L).** Реализуется ровно тем же
`pop_next_level` — и без него отладка уровней превращается в прохождение
игры руками. Делать в этом же шаге, не позже (см. §4).
**Приёмка шага 1:** дверь уровня 1 → уровень 2 играется целиком → его дверь
→ уровень 3 стартует (чомперы могут быть ещё не портированы — тогда
фиксируем как известное ограничение, а не «баг»).
---
## 3. Шаг 2 — второй тайлсет (palace, уровни 4+) — **СДЕЛАН**
> **Закрыт (ревизия 2026-08-11 по коду).** В дереве есть всё, что
> проектировалось ниже: `pop_pack_bg.py` печёт ДВА набора атласов
> (`pop_*` из VDUNGEON, `pal_*` из VPALACE, каскад с обменом приоритетов),
> `pop_bg_load` принимает тип набора и ставит флаг `pop_palace`, по которому
> `pop_room.c` выбирает дворцовую ветку (`wall_pattern` дворца — сплошная
> заливка + пять mono-полос, `PALACE_WALL_MONO_IDS`), решётчатые тайлы
> 25-29 есть и в `tile_table` (`pop_tile.c`), и в коллизии — `tile_is_floor`
> совпадает с `seg006:0628` тайл в тайл. Уровень 4 проходится smoke-тестом.
>
> Текст ниже оставлен как справочник по раскрою палитры и по тому, почему
> смена тайлсета — это перезапись 32 записей, а не переиндексация спрайтов.
**Ассеты.** `toolchain/pop_pack_bg.py` уже читает PNG каскадом
VDUNGEON→VPALACE (та же логика, что в игре), но печёт ОДИН набор атласов
(`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl` ≈ 75 КБ). Нужен второй
набор из VPALACE (`pal_env*.atl` / `pal_wall.atl`), плюс `torch_debris` —
тайл, который встречается только на palace-уровнях. По EMM это ещё ~6
страниц при бюджете ~3.3 МБ — не проблема.
**Палитра — главный технический вопрос, и он уже решён раскроем.**
Тайлсет живёт в слотах `0x50..0x5F` (env) и `0x60..0x6F` (wall); Кид, меч,
страж, склянки — в других слотах. Значит смена тайлсета = перезапись 32
записей палитры (`gfx_pal_set` на обе страницы, как `flash_bg` в
`sprpop.c`), а НЕ перезагрузка `kid.pal` и не переиндексация спрайтов.
Сделать `pal_dungeon.bin` / `pal_palace.bin` (по 32 записи) и грузить при
смене типа уровня. Проверить артефактом: скриншот palace-комнаты против
рендера `render_room.py` для того же уровня.
**Вариантные цвета уровней** (`tbl_level_color`, `level_var_palettes` — это
уже 1.3, в 1.0 их нет): по той же механике, тот же диапазон слотов. Решение
на будущее — сначала базовые два тайлсета, потом при желании цвета.
**Выбор набора в коде.** `pop_bg_load()` сейчас грузит фиксированные имена;
превратить в `pop_bg_load(type)` с двумя таблицами имён + выгрузка старых
атласов при смене типа (`atlas_free`). Переключение — только на границе
уровня, не в кадре.
---
## 4. Читы SDLPoP: что взять на следующем этапе
Из `../SDLPoP/README.md` (раздел Cheats). У нас уже есть: **K** — убить
стража, **I** — бессмертие (наш, в оригинале нет), **S** — выдать меч (наш),
**+/−** — обход комнат (`ROOMNAV`, наш).
**Брать сразу вместе с переходами уровней** (без них отладка дороже самой
работы):
| Чит | Что даёт | Цена |
|-----|----------|------|
| **Shift+L — следующий уровень** | единственный вменяемый способ тестировать уровни 2..15 | тривиально: `++pop_next_level` из §2.2 |
| **R — воскресить Кида** | у нас респавн по ↑ + таймаут; порт `resurrect` ближе к оригиналу и не мешает управлению | низкая |
| **Shift+S / Shift+T — +1 HP / +максимум** | отладка боёвки без «ровно трёх попыток»; честная замена нашему читу бессмертия | низкая, HP-машинерия уже есть |
| **[ и ] — сдвинуть Кида на пиксель** (debug-чит SDLPoP) | прямо бьёт в наш класс багов «окклюзия/шов на один пиксель» — воспроизведение позы без ловли момента | тривиально |
**Брать во вторую очередь:**
| Чит | Почему позже |
|-----|--------------|
| **H / J / U / N + Ctrl+B — смотреть соседние комнаты** | требует честной модели `drawn_room ≠ Kid.room` (наш S3-straddle, каркас есть: `update_kid_render_dx`). Зато потом заменяет самодельный `ROOMNAV` и попутно закрывает straddle-задачу |
| **Shift+W — медленное падение (feather)** | ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` уже есть, но не проверена ничем — чит станет её единственным тестом |
| **C / Shift+C — номера комнат** | у нас номер рисуется палочками именно потому, что текст тянет 2 КБ знакогенератора в W2 (`sprpop.c`). Ждёт своего шрифта |
**Не брать:** `Shift+I` (переворот экрана), `Shift+B` (blind mode) —
развлекательные, к отладке порта отношения не имеют. `/+` (время) — нужен
таймер уровня, которого у нас нет (Фаза 6).
**Отдельно, дорого, но очень ценно — `F6`/`F9` (quicksave/quickload точного
состояния).** Это сериализация `Char` + `room_modif` всех комнат + trob'ов +
состояния стражей. Даёт то, чего нам сейчас сильно не хватает:
воспроизводимый регресс в MAME («вот кадр, где баг») вместо ручного подхода
к позиции. Кандидат сразу после того, как заработают уровни.
---
## 5. Риски и что проверить артефактом до кодинга
1. **Размер кода.** Замер сборки 2026-08-01: `_CODE` 25 119 Б,
куча ~2.4 КБ, банк 2 (`pop_bg`) 13 792 / 16 384, банк 3 (`pop_map`)
6 331, банк 1 (`guards`) 1 896, банк 4 (`pop_gdraw`) 2 236. Чомперы,
зеркало, скелет и второй тайлсет пойдут в банк 2 — там осталось 2.6 КБ.
**Прежде чем начинать §3, посчитать, куда лягут новые тайлы**, иначе
повторится история «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные
номера банков есть (5+), гранулярность — файл.
2. **Спецсобытия уровней** (`seg002.c`: `level3_set_chkp`, `sword_disappears`,
`Jaffar_exit`, зеркало, мышь) — их НЕ надо портировать заранее. Для
уровней 2 и 3 нужен только чекпойнт уровня 3. Остальное — по мере
подхода к уровню.
3. **Чомперы** (уровень 3 и почти все дальше) — отдельная механика
(`animate_chomper` + коллизия + смерть); шаблон работы тот же, что у
пик/ворот, см. `gates_spikes_plan.md`.
4. **`tbl_guard_type = -1`** на уровнях 14/15: без проверки страж
«появится» из неинициализированных данных.
5. **Уровень 0 (демо)** существует в данных, но в скоуп не входит.