Files
Sprinter-SDCC/applications/SprPoP/docs/levels_12_15_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

25 KiB
Raw Blame History

Что осталось на уровнях 12, 13, 14, 15 и 0

Статус: разбор по ../SDLPoP/src/, 2026-08-13. Продолжает levels_plan.md (машинерия уровней и тайлсеты — уже сделаны). Здесь только СПЕЦСОБЫТИЯ, которых у нас ещё нет.

Правило проекта: источник истины — SDLPoP; все ссылки ниже даны на функцию и строку, чтобы порт начинался с чтения, а не с гипотезы.


0. Что из этой области УЖЕ есть

Проверено грепом по SprPoP/:

механика где у нас статус
слот соперника, общий на всех Char pop_guard.c, pop_cdraw.c готово
check_shadow (спецвход тени) guards.c:310 готово для уровней 4/5/6
do_init_shad + таблицы init_shad_5/6 guards.c:284 готово
ИИ тени 4/5/6 guards.c:599/658/680 готово
диспетчер autocontrol_shadow guards.c:711 ветки 12 НЕТ
боёвка (удар/блок/парирование/HP соперника) guards.c, pop_ctrl.c готово
flash_color / flash_time pop_map.c, sprpop.c готово
add_life pop_map.c готово
таблицы уровней (guard_type, guard_hp, entry_pose, level_type) pop_level_cold.c:41..52 готово, включая 12=SHADOW, 13=VIZIER
loose-полы, make_loose_fall, mob pop_map.c, pop_room.c готово (без спецкейсов ур. 13)

То есть каркас есть весь; ниже — недостающие спецсобытия.


1. Уровень 12 — тень: встреча, бой, слияние

1.1 Подъём тени в комнате 15 (check_shadow, seg002:0070)

if (current_level == 12) {
    if (!united_with_shadow && drawn_room == 15) {
        Char.room = drawn_room;
        if (get_tile(15, 1, 0) == tiles_22_sword) return;   // меч ещё лежит
        shadow_initialized = 0;
        do_init_shad(init_shad_12, 7 /* fall */);
        return;
    }
}

Отличия от наших веток 5/6: тень поднимается в падении (seq 7), условием служит содержимое тайла (меч уже подобран) и флаг united_with_shadow. Таблица init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0} — то есть x=81, y=232, вправо, колонка 0, ряд 0.

Что добавить: ветку в pop_check_shadow + константу init_shad_12 + глобалы united_with_shadow, shadow_initialized.

1.2 ИИ тени (autocontrol_shadow_level12, seg002:1184)

Самая содержательная функция уровня. Три режима:

  1. Первый кадр в комнате 15: пока Кид не подошёл (Opp.x < 150) — shadow_initialized = 1; иначе тень ещё раз падает (do_init_shad).
  2. Кид с мечом (Char.sword >= sword_2_drawn) → тень дерётся обычным autocontrol_guard_active (у нас есть, guards.c:535). Особый случай: если тень уже ранена (offguard != 0 && guard_refrac != 0) — она убирает меч (move_4_down).
  3. Кид убрал меч → тень тоже убирает и идёт навстречу; на дистанции < 10СЛИЯНИЕ:
flash_color = color_15_brightwhite;  flash_time = 18;
add_life();                       // +1 к максимуму HP
united_with_shadow = 42;          // время вспышки Кид-тень
Char.charid = charid_0_kid;  savekid();   // Кид ПЕРЕЕЗЖАЕТ на место тени
clear_char();                     // тень со сцены

Плюс «если Кид бежит к тени — тень бежит к Киду» (кадры бега 3..14 и шага 127..132).

Что добавить: autocontrol_shadow_level12 в guards.c + ветку в диспетчере autocontrol_shadow (там уже три ветки, будет четвёртая).

1.3 Общий урон (do_delta_hp, seg000:1518)

if (Opp.charid == charid_1_shadow && current_level == 12 && guardhp_delta != 0)
    hitp_delta = guardhp_delta;      // ранил тень — ранил себя

Три строки, но без них бой с тенью теряет смысл. У нас do_delta_hp портирован — добавить условие.

1.4 Таймер вспышки (do_timers, seg003:503)

if (united_with_shadow > 0) {
    --united_with_shadow;
    if (united_with_shadow == 0) { --united_with_shadow; /* → -1 */ ... }
}

united_with_shadow живёт как счётчик, потом как «уже слились» (−1). На него смотрят check_shadow и check_can_guard_see_kid.

1.5 Луч видимости (check_can_guard_see_kid, seg003:702)

if ((Guard.charid != charid_1_shadow || current_level == 12) && ...

У нас (guards.c:103) условие про тень нужно сверить: на уровне 12 тень ОБЯЗАНА быть видимой (иначе Кид не достанет меч и бой не начнётся).

1.6 Меч исчезает (sword_disappears, seg002:0536)

if (current_level == 12 && Char.room == 18) {
    get_tile(15, 1, 0);
    curr_room_tiles[curr_tilepos] = tiles_1_floor;
    curr_room_modif[curr_tilepos] = 0;
}

Срабатывает при уходе Кида ВПРАВО из комнаты 18 (leave_room, ветка 1). У нас есть pop_level_set_tile — порт на пять строк.

1.7 Переход 12 → 13: НЕТ двери уровня, есть «бесшовный выход»

Проверено по данным (res2012.bin / res2013.bin) — портала действительно нет, уровень кончается фактом попадания в комнату:

// play_level_2, seg000:900
} else if (custom->tbl_seamless_exit[current_level] >= 0) {
    if (Kid.room == /*23*/ custom->tbl_seamless_exit[current_level]) {
        ++next_level;
        stop_sounds();
        seamless = 1;
    }
}

tbl_seamless_exit[12] = 23 (пара «уровень, комната» читается из оригинального PRINCE.EXE, options.c:724). Дальше геометрия складывается так:

комната ряд 1 что происходит
ур. 12 13 floor bigpil empty empty wall wall … Кид бежит ВЛЕВО с колонки 0
ур. 12 23 empty ×6, floor floor floor floor попал сюда → уровень сменился
ур. 13 23 floor bigpil floor ×6 bigpil floor старт: ряд 1, кол 9, seq_84_run

Связи: комната 13.left = 23, и в комнату 23 больше ниоткуда не войти. Номер комнаты у обоих уровней один и тот же (23), стартовая позиция уровня 13 — BP_START = 23, поз 19 (ряд 1, кол 9), dir 0, а tbl_entry_pose[13] = 2 даёт «вбегающий» вход (seq_84_run, seg003:172). То есть Кид вбегает в комнату слева-направо… нет, Char.direction = ~level.start_dir — влево, тем же ходом, каким выбежал из уровня 12. Швов не видно.

Что делает флаг seamless (всего два места, оба косметические):

  • start_level, seg003:158 — НЕ сбрасывает HP: Кид уносит на уровень 13 то здоровье, с которым добежал;
  • show_level, seg008:1861/1870 — не показывает заставку «LEVEL 13» и тут же гасит флаг.

Двери уровня при этом на карте есть, но не при делах: у уровня 12 она одна (комната 3, tilepos 23) — это ВХОД (стартовая комната 12-го — 3), а у уровня 13 их две (комната 3 tilepos 13 и комната 5 tilepos 24) — это выходы, открываемые кнопкой из Jaffar_exit.

Что это значит для нас. Наш переход уровня сейчас идёт только через SEQ_END_LEVEL в двери. Для 12-го нужен второй триггер — проверка в главном цикле «pop_current_level == 12 && cur_room == 23pop_next_level++» плюс флаг seamless, который пропустит сброс HP в pop_start_level. Обе правки маленькие и локальные.


2. Уровень 13 — Джафар

2.1 Кто такой Джафар

tbl_guard_type[13] = 3VIZIER.DAT (у нас в pop_level_cold.c уже 3). tbl_guard_hp[13] = 6. Отдельного ИИ у него нет:

void autocontrol_Jaffar() { autocontrol_guard(); }   // seg002:0697

То есть бой с Джафаром — обычный бой стражи, отличаются только спрайты, HP и три спецсобытия ниже. Это хорошая новость: боёвка у нас есть.

2.2 Встреча (meet_Jaffar, seg002:0544)

if (current_level == 13 && leveldoor_open == 0 && Char.room == 3) {
    play_sound(sound_29_meet_Jaffar);
    guard_notice_timer = 28;      // Джафар ждёт 28/12 ≈ 2.33 с
}

Срабатывает при уходе Кида ВПРАВО (leave_room, ветка 1). Пара к нему — в autocontrol_guard_inactive (seg002:0734), она у нас уже портирована (guards.c:401), но без условия по уровню:

if (can_guard_see_kid) {
    if (current_level != 13 || guard_notice_timer == 0) move_down_forw();
}

Что добавить: глобал guard_notice_timer + его тик в do_timers (seg003:509) + оба условия.

2.3 Победа (on_guard_killed, seg006:1929)

} else if (current_level == 13) {
    flash_color = color_15_brightwhite;  flash_time = 18;
    is_show_time = 1;
    leveldoor_open = 2;              // ← ключ к выходу
    play_sound(sound_43_victory_Jaffar);
}

и парная Jaffar_exit (seg002:0517), срабатывающая при уходе Кида ВЛЕВО:

if (leveldoor_open == 2) { get_tile(24, 0, 0); trigger_button(0, 0, -1); }

То есть смерть Джафара не открывает дверь сама — она ставит флаг, а дверь открывается кнопкой, «нажатой» при уходе влево. trigger_button у нас есть.

2.4 Падающие плиты (check_fall_flo, seg000:1319)

if (current_level == 13 && (drawn_room == 23 || drawn_room == 16)) {
    curr_room = room_A;                       // комната СВЕРХУ
    for (curr_tilepos = 22; curr_tilepos <= 27; ++curr_tilepos)
        make_loose_fall(-(prandom(0xFF) & 0x0F));   // ОТРИЦАТЕЛЬНЫЙ модификатор
}

Вот это и есть «плиты появляются»: при входе в комнаты 23/16 шесть плит ряда 2 комнаты СВЕРХУ получают отрицательную фазу — то есть отложенный старт, и сыплются на Кида вразнобой. Зовётся из check_the_end при смене комнаты.

Три спецкейса уровня 13 в loose-механике, без них это не работает:

место что зачем
animate_loose, seg007:823 при modif & 0x80 НЕ останавливать тряску иначе отрицательная фаза не досчитает до нуля и плита не упадёт
loose_make_shake, seg007:949 на уровне 13 сотрясение НЕ трясёт плиты иначе отложенные плиты сбрасываются в 0x80
fell_on_your_head, seg007:1218 плита бьёт и в БЕГЕ (кадры 5..14) на прочих уровнях бегущего не задевает

У нас первый пункт критичен: pop_loose_tick трактует бит 7 как «тряска» и на >= 0x84 гасит фазу — отрицательный старт умрёт, не начавшись. Заодно это ровно та же ветка, что мы правили сегодня в LOOSE-ROOM-CHANGE, так что код на виду.

2.5 Поза входа

tbl_entry_pose[13] = 2 — у нас в таблице уже есть; проверить, что режим 2 (seg003:172) реализован.


3. Уровень 14 — принцесса и конец игры

Боя нет вовсе (tbl_guard_type[14] = -1). Всё сводится к одному событию:

// check_the_end, seg000:1299
if (current_level == 14 && drawn_room == 5) end_sequence();

end_sequence (seg001:573) → end_sequence_anim (seg001:332): катсцена «Кид добежал до принцессы» — обнимаются, появляется мышь, затухание, Hall of Fame. Персонажи там играются ТЕМ ЖЕ интерпретатором seqtbl (seq_108_princess_turn_and_hug, seq_101_mouse_stands_up), то есть движок у нас уже подходит — нужны спрайты принцессы (PRINCESS.DAT) и раскадровка.

Оценка: это не игровая механика, а ролик. Логично делать вместе с интро и межуровневыми вставками — отдельным банком, как и договаривались (см. memory pop_banking_architecture). На проходимость игры не влияет: достаточно довести Кида до комнаты 5 и показать заглушку.


4. Уровень 15 — «уровень зелий» (защита от копирования)

Не часть сюжета. Это экран проверки подлинности из оригинала: после уровня copyprot_level игра подменяет номер на 15, показывает комнату с 14 зельями, на которых нарисованы буквы, и требует выпить нужное.

Механика (всё под USE_COPYPROT в SDLPoP):

место что делает
play_level, seg003:53 level_number == copyprot_level → грузим 15
redraw_screen, seg003:273 поверх зелий рисуются БУКВЫ (copyprot_letter)
load_alter_mod, seg008:1204 одно зелье в комнате делается «открытым» (тип 6)
animate_potion, seg007:259 на уровне 15 своя ветка перерисовки
up_pressed/do_pickup, seg005:657 выпитое зелье убирает букву из таблицы
seq эффект зелья, seg006:1896 синие зелья на уровне 15 отнимают ПОЛОВИНУ HP
выход, seg000:700 из 15 возвращаемся в copyprot_level

Рекомендация: не портировать. Это анти-пиратский экран 1989 года, требующий книжки-манускрипта; SDLPoP держит его выключенным по умолчанию (enable_copyprot). Единственное, что стоит взять — половинный урон синего зелья, если вдруг захочется полной совместимости; остальное только съест банк. Если решим делать — это отдельная фича «уровень 15», а не часть основного прохождения.


5. Уровень 0 — демо-уровень (аттракт)

res2000.bin у нас распакован. Это тот самый ролик, который крутится на титульном экране: Кид сам бежит, дерётся со стражем и убегает.

Как устроено:

место что
do_demo, seg006:1409 на уровне 0 вместо чтения клавиатуры зовётся do_demo() + control()
autocontrol_kid, seg002:702 Кид управляется тем же autocontrol_guard
do_auto_moves, seg002:1089 проигрыватель ЗАПИСИ ходов: таблица (time, move)
on_guard_killed, seg006:1928 на уровне 0 после убийства стража Кид убегает (checkpoint = 1, сброс демо)
demo_index / demo_time позиция в записи; у нас уже объявлены в guards.c:267

Хорошая новость: движок автодвижений (do_auto_moves) у нас уже есть — он нужен был тени на уровнях 4/5/6, и demo_time/demo_index объявлены там же. То есть демо-уровень — это в основном таблица ходов + ветка «Кидом управляет ИИ» в pop_ctrl.

Когда делать: вместе с интро/титульным экраном, не раньше. На прохождение не влияет.


6. Порядок работ

Порядок задан пользователем 2026-08-13: строго по номерам уровней, а не по дешевизне кода — уровень 13 не имеет смысла раньше, чем на него можно попасть.

  1. Уровень 12 — тень: init_shad_12, autocontrol_shadow_level12, united_with_shadow, общий урон, «убил тень — убил себя», sword_disappears, появление плит. Сделано 2026-08-13, хост-набор SprPoP/tests-host/t_shadow.c (45 проверок); живой проверки в MAME ещё не было — доска L12-SHADOW.
  2. Переход 12 → 13 (§1.7) — room-триггер вместо двери и флаг pop_seamless (не сбрасывать HP). Сделано там же.
  3. Уровень 13 — Джафар: guard_notice_timer, on_guard_killed/Jaffar_exit, три спецкейса loose (§2.4) и check_fall_flo. Плюс СПРАЙТЫ визиря (VIZIER.DAT) — без них он рисовался обычным стражем. Сделано 2026-08-13, хост-набор SprPoP/tests-host/t_jaffar.c (44 проверки); живой проверки в MAME ещё не было — доска L13-JAFFAR.
  4. Уровень 14 — довести до комнаты 5 и поставить заглушку вместо ролика; сам ролик — в общую задачу «катсцены».
  5. Уровень 0 и 15 — отложить: аттракт и защита от копирования на прохождение не влияют.

7. Сверка констант с ОРИГИНАЛЬНЫМИ данными (сделана 2026-08-13)

Всё ниже снято скриптом прямо с ../SDLPoP/data/LEVELS/res2013.bin и res2012.bin — не из головы и не из констант SDLPoP.

7.1 Падающие плиты уровня 13 — константы сходятся ТОЧНО

Комнаты, у которых в ряду 2 вообще есть loose:

комната loose в колонках комната снизу
17 2, 3, 4, 5, 6, 7 23
1 2, 3,    5, 6, 7 16
14 2 24

Связи: комната 23: up = 17, комната 16: up = 1. То есть loose_tiles_room_1 = 23 и room_2 = 16 — это ровно те две комнаты, над которыми лежит ПОЛНАЯ гряда плит, а first_tile = 22, last_tile = 27 — ровно ряд 2, колонки 2..7:

комната 23 → сверху 17: 22:LOOSE 23:LOOSE 24:LOOSE 25:LOOSE 26:LOOSE 27:LOOSE   [6/6]
комната 16 → сверху  1: 22:LOOSE 23:LOOSE 24:empty 25:LOOSE 26:LOOSE 27:LOOSE   [5/6]

Два вывода:

  • диапазон 22..27 — надмножество: в комнате 1 тайл 24 пустой, и make_loose_fall его молча пропустит (проверяет тип тайла). Копировать константы можно как есть;
  • пара «комната 14 → 24» НЕ входит в спецсобытие намеренно — одна плита, это обычный loose.

Важное следствие, которого не было в плане: стартовая комната уровня 13 — 23 (BP_START = 23, поз 19, dir 0), а check_fall_flo зовётся из draw_level_firstcheck_the_end (seg003:217). Значит плиты начинают сыпаться СРАЗУ при входе на уровень, это его первый кадр, а не событие где-то в середине.

7.2 Джафар — один страж со skill 9

В res2013.bin заполнен ровно один слот стража:

комната тайл ряд, кол направление skill
1 7 0, 7 255 (влево) 9

И это сходится с meet_Jaffar: событие срабатывает, когда Кид уходит ВПРАВО из комнаты 3, а комната 3: right = 1 — то есть ровно туда, где стоит Джафар. Skill 9 у нас поддержан: NUM_GUARD_SKILLS = 12, все шесть таблиц вероятностей (guards.c:411..415) имеют индекс 9. HP = tbl_guard_hp[13] = 6pop_level_cold.c уже стоит).

Jaffar_exit тоже проверен: комната 24, tilepos 0 = opener (кнопка), модификатор 0 — то есть trigger_button(0,0,-1) жмёт реальную кнопку, а не пустой тайл.

7.3 Уровень 12 — меч на месте

комната 15, tilepos 1 = SWORD — условие подъёма тени (get_tile(15,1,0) == tiles_22_sword) на наших данных выполняется. комната 18: right = 19sword_disappears срабатывает при уходе вправо.

Комната 15 целиком (по ней видно всю сцену встречи с тенью):

ряд 0:  floor   SWORD   floor   torch   torch   LOOSE   LOOSE   floor   empty   empty
ряд 1:   wall    wall    wall   empty   empty   empty   empty    wall   floor   floor
ряд 2:   wall  opener  pillar   empty   empty   empty   empty    wall  pillar   empty

7.4 pop_loose_tick действительно убьёт отрицательную фазу

Подтверждено чтением кода: `m = ++pop_loose_modif[pos]; if (m & 0x80) { if (m

= 0x84) → сброс в 0 }. Для стартового 0xF0..0xFFпервый же тик даётm >= 0x84` → фаза обнуляется, плита не падает. Это и есть та правка №1 из таблицы §2.4, и она обязательна.

7.5 init_shad_12 и байтовое переполнение y

init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0} → frame 15, x 81, y 232, вправо, колонка 0, ряд 0, action 0 + seq 7 (fall). Поле y в char_typebyte (беззнаковое), то есть 232 лежит НИЖЕ поля (192), и падение уводит его дальше с переполнением байта: тень «падает» из-под экрана и появляется сверху. У нас pop_char_t.y тоже uint8_t — поведение переносится без правок; при портировании просто не «чинить» это как ошибку.

Отдельно про ряд: curr_row берётся ИЗ ТАБЛИЦЫ и равен 0, хотя pop_y_to_row(232) дал бы 1. do_init_shad копирует семь полей как есть и ряд не пересчитывает, так что «согласовать» их — значит разойтись с оригиналом. Закреплено тестом shadow12_rises_when_sword_gone.