Контракты модуля «control-fix»

Внутренний документ. План — docs/archive/plan/control-fix.md, журнал — docs/archive/plan/control-fix-progress.md. Контрактный тест — tests/game/test_control_fix_contracts.gd (форма стыков; ломается при смене формата без правки контракта). v1 — то, что есть в коде на v1.0.0 (3820bda). Менять интерфейс — только через координатора: версия +1, что изменилось, потребители правятся в том же шаге.

С1. ControlInput — управление пилота за шаг — v2

Владелец: scripts/core/control_input.gd. Источники: InputController, Autopilot, BotPilot, сеть. Потребители: FlightModel.step, GroundRun.step, Glider, визуал.

v1 (до CF-3):

  • pitch: float ∈ [−1, 1] — +1 трапеция от себя (нос вверх), −1 на себя; на земле — угол носа крыла (+ нос вверх).
  • roll: float ∈ [−1, 1] — +1 вправо. В полёте — крен (режим по weight_shift); на земле — только курс (крыло не кренит), К3 v2.
  • weight_shift: bool; run: bool — разбег; walk: float ∈ [−1, 1] — шаг.

v2 (CF-3, решение пользователя 01.10, вариант В):

  • Новое поле turn: float ∈ [−1, 1], по умолчанию 0 — поворот курса на земле стоя и шагом (+ вправо, по часовой); в полёте и на бегу не используется.
  • pitch на земле (стоя, шагом, на бегу) — нос крыла, тот же смысл и знак, что в полёте (+ от себя = нос вверх), полный ход [−1, 1].
  • roll на земле (стоя, шагом, на бегу) — заданный крен крыла «рукой пилота», + вправо; полный ход = предельный крен руки (flight.json → ground_bank, ключ с физическим обоснованием — CF-3). Курс roll на земле больше не задаёт.
  • run, walk, weight_shift — без изменений.

С2. InputController → ControlInput — v3

Владелец: scripts/game/input_controller.gd. Потребитель: Game.tick (input_controller.update(dt) → glider.set_input). Не меняется: update(dt: float) -> ControlInput; enabled, hands_off, on_ground; set_mouse_captured(on), mouse_captured; правая кнопка — осмотреться (mouse_mode() — до v3) (трапеция держит положение).

v1: мышь (bar) — только в воздухе; на земле W/S — шаг, Shift+W — разбег, A/D — поворот, ↑/↓ — подстройка носа ±0,3 поверх run_nose_neutral + auto_nose; защёлка клавиш при отрыве (controls.json → takeoff_latch, is_latched). Мышь по умолчанию look. CF-2 (без смены версии): в bar смещение мыши на земле не копится.

v2 (CF-3, решение пользователя 01.10):

  • controls.json → mouse.mode по умолчанию bar.
  • Стоя и шагом (Shift не зажат): W/S → walk, A/D → turn; нос и крен крыла (pitch, roll) — стрелки, мышь в bar, стик геймпада. Клавиши крыла на земле — те же действия, что в полёте (pitch_pull_in/pitch_push_out, roll_left/roll_right, invert_pitch); какой клавише какой знак — только карта ввода controls.json → keys (её раскладку по умолчанию меняет модуль feature/ui-controls: W — от себя, S — на себя), в наземной логике знак клавиш не закладывается. Стоя W/S/A/D из этих действий исключаются (они — шаг/поворот), на бегу — входят.
  • Действия ввода (CF-3): новые turn_left [A], turn_right [D] — поворот стоя/шагом; удалены nose_up, nose_down, takeoff_latch; стоя трапецию не двигают клавиши, занятые walk_*/turn_* (InputController.GROUND_MOVE_ACTIONS).
  • Разбег: зажат Shift → run = true (W не нужен; блокировка run_blocked — как была); W/S, A/D, стрелки, мышь, стик → pitch, roll как в полёте; walk = 0, turn = 0.
  • Мышь в bar на земле управляет pitch/roll и стоя, и на бегу; смещение мыши на отрыве непрерывно (правка CF-2 «на земле не копится» заменяется этим).
  • Защёлки при отрыве нет (takeoff_latch, is_latched убраны): на отрыве pitch/roll непрерывны.
  • Базовый нос без ввода (CF-3): pitch = 0 — угол разбега самого крыла (wing.launch.alpha_neutral_deg, 16–17°), тот же ноль, что трим в полёте; ввод — полный ход ±1 (≈ ±20° α). ground.run_nose_neutral, ground.auto_nose, подстройка ↑/↓ игрока убраны (автоматика носа у ботов — configs/bots.json → launch.run_nose/launch.auto_nose). Трапеция одна на земле и в полёте (клавиши + мышь, затем стик); «в центр» (X) — только в полёте.

v3 (02.10, правка модуля ui-controls — У1 v3 / У2 v2 в docs/contracts/ui-controls.md, решение пользователя «крыло через WASD не нужно, настройку режима мыши убрать»):

  • mouse_mode() и controls.json → mouse.mode убраны: мышь всегда крыло (бывший bar; захват — как был; правая кнопка — осмотреться).
  • W/S/A/D сняты с действий трапеции (pitch_*/roll_* — только стрелки): на земле W/S → walk, A/D → turn (стоя и шагом); на разбеге (Shift) W/S/A/D ничего не делают (walk = turn = 0, крыло — мышь, стрелки, стик); в полёте — обзор головой (look_*). Исключение клавиш шага из трапеции (GROUND_MOVE_ACTIONS) больше не нужно.
  • Остальное v2 — без изменений.

С3. Пилот на земле — GroundRun (start-fixes К3) — К3 v3

Базовые контракты — docs/contracts/start-fixes.md → К3 v2 (крен на земле, переход в полёт), К4 v1 (поза крыла); их тест — tests/game/test_start_fixes_contracts.gd. Владелец в этом модуле — CF-1 (до CF-3), затем CF-3.

К3 v3 (CF-3):

  • Стоя/шагом курс — от input.turn (pilot.walk.turn_rate_dps), крыло этим не кренится; на бегу курс — от крена крыла (дуга: боковое ускорение от боковой составляющей силы крыла, |a| ≤ pilot.run.turn_accel_max_ms2), input.turn не используется.
  • input.roll на земле — заданный крен φ_зад регулятора «руки пилота»: момент руки −I/τ²·(φ − φ_зад) − 2I/τ·φ̇, предел pilot_moment_max_nm · feet_load — как в v2. Без ввода φ_зад = 0 (поведение v2).
  • input.pitch на земле — нос крыла на полный ход (смысл как в v2).
  • Заданный крен: φ_зад = roll · ground_bank.command_max_deg (15°: ход рук на стойках ≈ ±0,1 м при плече 0,35 м → atan(0,2/0,7) ≈ 16°; меньше fail_bank_deg 20°). pilot_moment(m, bank, rate, load_frac, inertia) — сигнатура прежняя, вторым аргументом передаётся φ − φ_зад.
  • Дуга на бегу: a = a_кр + clamp(a_зад − a_кр, ±N/W·a_max), |a| ≤ a_max = turn_accel_max_ms2; a_зад = g·tg φ_зад (наклон в поворот вместе с крылом), a_кр = (1 − N/W)·g·sin φ (боковая составляющая подъёмной силы); ω = a/v, не больше поворота на месте. К отрыву (N/W → 0) курс — от фактического крена, как вираж в воздухе.
  • Порогов по времени в старте и взлёте нет (решение пользователя 01.10, план п. 4): отрыв — только L ≥ W при несорванном крыле. GroundRun.failure ∈ {"wingtip", ""} — wingtip: конец опущенной креном консоли на земле (геометрия: полуразмах от киля на takeoff.wing_height_m); nose_high, nose_low, tailwind, crosswind, weak_run убраны (сорванное/опущенное крыло и попутный ветер просто не дают отрыва; бросил бежать — стоит). Из flight.json → takeoff убраны grace_s, fail_time_s, check_min_airspeed_ms, nose_high_margin_deg, nose_low_alpha_deg, tailwind_max_ms, weak_run_min_time_s, max_run_time_s; из ground_bank — fail_bank_deg. Локаль — launch_fail_wingtip. FlightStats: полёт засчитан по геометрии (≥ 10 м над землёй или ≥ 50 м от точки отрыва), без 10 с. Автопилот бежит сразу после замера ветра стоя (без stand_s).
  • Переход земля → полёт (CF-3, вместо takeoff.grace_s): после отрыва пилот ещё на ногах, пока ступни ниже takeoff.upright_clear_m (1,0 м, длина ноги); касание в это время — не посадка, снова GroundRun (разбег продолжается, отрыв опять по L ≥ W); угол атаки — как на разбеге (launch.alpha_neutral_deg + pitch·range). Автопилот и боты бегут, пока ступни ниже этой высоты.
  • GroundRun.step(...), Result, phase, failure, feet_load, wind_moment_nm, hold_limit_nm, roll_inertia — без изменений; pilot_moment может получить заданный крен — тогда новая сигнатура записывается сюда.