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

Внутренний документ. План — docs/archive/plan/ui-controls.md. Контрактный тест — tests/game/test_ui_controls_contracts.gd. Менять — только через координатора модуля: версия +1, что изменилось, потребители правятся в том же шаге.

Опора: ControlInput.pitch (control-fix С1) — без изменений

ControlInput.pitch ∈ [−1, 1]: +1 — трапеция от себя (нос вверх, торможение, угол атаки больше), −1 — на себя (нос вниз, разгон). Этот модуль смысл поля не меняет — меняет только, какие устройства ввода дают какой знак.

У1. Раскладка тангажа и крена — v3 (этап 3, 02.10)

Владелец: этот модуль (configs/controls.json → keys, invert_pitch; знак стика в InputController._apply_gamepad). Потребители: InputController (полёт и земля), экран «Управление» (configs/ui.json → controls_help), подсказки/локали, документация, автопилот и тесты (жмут действия по имени).

Действия (имена — смысл, не клавиша):

  • pitch_push_out = [Up] — трапеция от себя → pitch +; pitch_pull_in = [Down] — на себя → pitch −.
  • roll_left = [Left], roll_right = [Right] — крен (roll −/+).
  • W/S/A/D не назначены ни на одно действие трапеции (pitch_*, roll_*).
  • Мышь — всегда крыло (трапеция); настройки режима мыши нет (controls.json → mouse.mode нет). Мышь захвачена (InputController.mouse_captured, M; mouse.capture_on_start): от себя (курсор вверх, relative.y < 0) → pitch +, вбок → roll; зажата правая кнопка — мышь крутит голову в кабине, трапеция держит положение. Не захвачена — мышь трапецию не двигает, трапеция держит последнее положение мыши. Свободная камера (hands_off) — мышь крутит камеру, смещение трапеции не копится (UC-5).
  • Стик: вперёд (ось gamepad.pitch_axis < 0) → pitch +.
  • invert_pitch (по умолчанию false): true меняет знак тангажа для стрелок, мыши и стика разом («как в самолёте»).
  • Земля (С2 v2 control-fix): трапеция (нос и заданный крен крыла) — те же действия pitch_*/roll_* (стрелки), мышь, стик — стоя, шагом и на разбеге.
  • Экран «Управление»: строки «идти/шаг назад/поворот» привязаны к walk_*/turn_*, строки трапеции — к pitch_*/roll_*; поля mouse_mode у строк нет.
  • Инвариант (тест): в полёте по умолчанию ↑, мышь от себя, стик вперёд дают pitch > 0 и рост угла атаки; ↓, мышь на себя — pitch < 0; инверсия — наоборот; W/S/A/D — pitch = roll = 0.

История: v1 (v1.0.0) — W/↑ на себя, «как в самолёте»; v2 (01.10) — W/↑ от себя, мышь bar/look; v3 (02.10, решение пользователя «крыло через WASD не нужно, настройку убрать») — W/S/A/D сняты с трапеции, режим мыши убран (мышь всегда крыло).

У2. W/S/A/D — пилот на земле, голова в полёте — v2 (этап 3, 02.10)

Владелец: этот модуль. Потребители: InputController, CameraRig, Game (связка), экран «Управление» и тексты, автопилот (не затронут — жмёт действия по имени).

Действия (configs/controls.json → keys): на земле walk_forward = [W], walk_back = [S], turn_left = [A], turn_right = [D] (С2 v2); в полёте look_up = [W], look_down = [S], look_left = [A], look_right = [D].

Условие — InputController.keys_look() -> bool: true ⇔ enabled и не hands_off и не on_ground. От мыши (захват) не зависит.

  • Земля (стоя, шагом, разбег): W/S — шаг (ControlInput.walk), A/D — поворот на месте (turn); на разбеге (Shift, CF-3) walk = turn = 0 — бег вперёд Shift, курс — от крена крыла. W/S/A/D в трапецию не идут; голову не крутят (keys_look() = false).
  • Полёт: W/S/A/D в трапецию не идут (не назначены, У1 v3); при keys_look() в камере кабины (CameraRig.mode == "cockpit") удержание look_left/look_right меняет рыскание головы (+ влево), look_up/look_down — тангаж (+ вверх) со скоростью camera.json → cockpit.head.key_rate_deg_s (°/с, > 0), в пределах cockpit.head (yaw_limit_deg, pitch_up_limit_deg, pitch_down_limit_deg); отпустил — голова остаётся; V/средняя — плавно вперёд. set_look (скриншоты) — клавиши голову не двигают.
  • Камера chase — клавиши обзора ничего не делают; free — без изменений (WASD двигают её, крыло без рук, hands_off).
  • Отрыв/касание: вклад W/S/A/D в трапецию 0 и до, и после — скачка нет; зажатая клавиша меняет роль сразу (защёлки нет).

Связка: CameraRig.keys_look_fn: Callable () -> bool; не задана — false. Game ставит camera.keys_look_fn = input_controller.keys_look. Наблюдаемое для тестов: CameraRig.head_look_deg() -> Vector2 — поворот головы (x — рыскание + влево, y — тангаж + вверх), °, без взгляда на прибор и без look_down_deg.

Инварианты (тесты test_u2_*, test_gameplay.gd::test_ground_bar_keys): полёт (с захватом мыши и без): W/S/A/D 0,5 с → pitch = roll = 0 (|·| < 1e-6), keys_look() = true; ↑ → pitch > 0, → → roll > 0; action_press("pitch_push_out") → pitch > 0. Земля стоя: keys_look() = false; W → walk > 0, pitch = 0; A → turn < 0, roll = 0; разбег (Shift): W/A → pitch = roll = 0, walk = turn = 0, ↑ → pitch > 0. Камера кабины с keys_look_fn = true: A 0,5 с → рыскание ≈ +45° (при 90°/с), W 0,5 с → тангаж > 0; с false — 0.

История: v1 (этап 2) — обзор с клавиш только при мыши-трапеции с захватом, в полёте и на разбеге; v2 (02.10) — обзор с клавиш в полёте всегда, на земле никогда; режима мыши нет.