From 58ce2d70a8e194dac2f911e0009acfd99de427c3 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:05:07 +0200 Subject: [PATCH 1/3] build: disable C++ module scanning --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 09de7a8..56d824c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ add_compile_definitions(_AMD64_) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # --- Global MSVC Runtime Configuration (/MT) --- # This ensures static runtime linking, avoiding "Win32 Error 126" on From 208b29c1b611bffade485e933969a7f3a773d406 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:06:03 +0200 Subject: [PATCH 2/3] refactor: share SmootherStep, Lerp and IsRightHandDrive --- src/effects/Telemetry.hpp | 6 ++++++ src/effects/manual/cabin_walk/CabinLayouts.hpp | 6 ------ src/effects/manual/cabin_walk/CabinWalkEffect.cpp | 13 +++---------- src/math/Units.hpp | 9 +++++++++ 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/effects/Telemetry.hpp b/src/effects/Telemetry.hpp index 52d9d9b..66a57a5 100644 --- a/src/effects/Telemetry.hpp +++ b/src/effects/Telemetry.hpp @@ -29,6 +29,12 @@ inline bool IsElectric(const SPF_TruckConstants &constants) { return constants.adblue_capacity <= 0.01f; } +// True if the truck's driver sits on the right: the default head position, +// relative to the cabin's pivot on its centerline, is right of it. +inline bool IsRightHandDrive(const SPF_TruckConstants &constants) { + return constants.head_position.x > 0.0f; +} + // engine_rpm crossing this (near-zero) is the "engine has started" signal. // engine_enabled lags the real catch by 1+ second, which read as buzz or // shudder firing well after the fact. diff --git a/src/effects/manual/cabin_walk/CabinLayouts.hpp b/src/effects/manual/cabin_walk/CabinLayouts.hpp index 0efab52..5340471 100644 --- a/src/effects/manual/cabin_walk/CabinLayouts.hpp +++ b/src/effects/manual/cabin_walk/CabinLayouts.hpp @@ -82,12 +82,6 @@ void NormalizeCabinLayout(CabinLayout &layout); // for a right-hand drive truck (its positions already fit, see above). CabinLayout WithMirroredYaws(const CabinLayout &layout); -// True if the truck's driver sits on the right: the default head position, -// relative to the cabin's pivot on its centerline, is right of it. -inline bool IsRightHandDrive(const SPF_TruckConstants &constants) { - return constants.head_position.x > 0.0f; -} - // "_" of the truck model, reduced to [A-Za-z0-9_] so it // holds as one config key segment; empty if the truck isn't known yet. std::string TruckLayoutKey(const SPF_TruckConstants &constants); diff --git a/src/effects/manual/cabin_walk/CabinWalkEffect.cpp b/src/effects/manual/cabin_walk/CabinWalkEffect.cpp index b1cc970..0876b93 100644 --- a/src/effects/manual/cabin_walk/CabinWalkEffect.cpp +++ b/src/effects/manual/cabin_walk/CabinWalkEffect.cpp @@ -13,6 +13,8 @@ namespace motioncab { using math::kDegToRad; using math::kPi; +using math::Lerp; +using math::SmootherStep; namespace { @@ -101,21 +103,12 @@ constexpr float kTurnShare = 0.85f; float Clamp01(float t) { return std::clamp(t, 0.0f, 1.0f); } -// 0 to 1 with zero speed and acceleration at both ends (quintic), so a move -// neither starts nor stops with a jolt. -float SmootherStep(float t) { - t = Clamp01(t); - return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); -} - // 0 at both ends, 1 at mid-move, with zero slope at the ends. float Bell(float t) { const float s = std::sin(kPi * Clamp01(t)); return s * s; } -float Lerp(float a, float b, float t) { return a + (b - a) * t; } - // Into [-pi, pi], so a turn never goes across the back: the yaw limits stop // at +-180 degrees, and a turn across them would jump. float WrapYaw(float yaw_rad) { return std::remainder(yaw_rad, 2.0f * kPi); } @@ -665,7 +658,7 @@ void CabinWalkEffect::UpdateWalking(float dt) { void CabinWalkEffect::OnTruckConstantsChanged( const SPF_TruckConstants &constants) { const std::string key = TruckLayoutKey(constants); - const bool right_hand_drive = IsRightHandDrive(constants); + const bool right_hand_drive = telemetry::IsRightHandDrive(constants); // Another cabin on the same model can move the driver's seat. const float centerline_x = CabinCenterlineX(constants); if (key == truck_key_ && right_hand_drive == right_hand_drive_ && diff --git a/src/math/Units.hpp b/src/math/Units.hpp index 9d454ab..42f3e90 100644 --- a/src/math/Units.hpp +++ b/src/math/Units.hpp @@ -14,6 +14,15 @@ inline constexpr float kMsToKmh = 3.6f; // in [0, 1]. inline float SmoothStep(float t) { return t * t * (3.0f - 2.0f * t); } +// 0 to 1 with zero speed and acceleration at both ends (quintic), so a move +// neither starts nor stops with a jolt. `t` is clamped to [0, 1]. +inline float SmootherStep(float t) { + t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); +} + +inline float Lerp(float a, float b, float t) { return a + (b - a) * t; } + // Brings an oscillator phase back into [0, 2*pi). fmod rather than a single // subtraction: a large dt spike (load hitch, alt-tab, a Quick Job cancel // reload) can advance a phase by several full turns in one frame, and From a564e2f3c12974f0946d4957a9a53689869c1a24 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:11:28 +0200 Subject: [PATCH 3/3] feat: add Natural Head Movement effect --- .github/PULL_REQUEST_TEMPLATE.md | 2 + CMakeLists.txt | 1 + CONTRIBUTING.md | 4 + README.md | 14 +- localization/cs.json | 25 ++ localization/de.json | 25 ++ localization/en.json | 25 ++ localization/es.json | 25 ++ localization/fr.json | 25 ++ localization/it.json | 25 ++ localization/ja.json | 25 ++ localization/ko.json | 25 ++ localization/nl.json | 25 ++ localization/pl.json | 25 ++ localization/pt_br.json | 25 ++ localization/ru.json | 25 ++ localization/tr.json | 25 ++ localization/uk.json | 25 ++ localization/zh.json | 25 ++ src/Plugin.cpp | 3 + src/core/Conflicts.hpp | 54 ++++ src/core/Settings.hpp | 12 + src/effects/ConfigurableEffect.hpp | 5 +- src/effects/Effect.hpp | 6 + src/effects/EffectManager.cpp | 57 +++- src/effects/EffectManager.hpp | 12 + .../cabin/NaturalHeadMovementEffect.cpp | 289 ++++++++++++++++++ .../cabin/NaturalHeadMovementEffect.hpp | 94 ++++++ src/ui/EffectTabs.cpp | 51 +++- 29 files changed, 962 insertions(+), 17 deletions(-) create mode 100644 src/core/Conflicts.hpp create mode 100644 src/effects/cabin/NaturalHeadMovementEffect.cpp create mode 100644 src/effects/cabin/NaturalHeadMovementEffect.hpp diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 61526ab..0886185 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,6 +30,8 @@ - [ ] Effect doesn't conflict with existing effects - [ ] Effect overrides `NeedsDriverSeat()`, if it only makes sense in the driver's seat +- [ ] Effects that shouldn't run together declared in + `conflicts::kPauses` (`src/core/Conflicts.hpp`), if any - [ ] Setting declared in `settings::kAll` (`src/core/Settings.hpp`) - [ ] New effect listed in `kEffects` (`src/ui/EffectTabs.cpp`) - [ ] Keybind declared in `src/core/Keybinds.hpp` (`kAllActions`), if any diff --git a/CMakeLists.txt b/CMakeLists.txt index 56d824c..69926f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,7 @@ set(PLUGIN_SOURCES "src/effects/cabin/EngineStartStopEffect.cpp" "src/effects/cabin/EngineVibrationEffect.cpp" "src/effects/cabin/IdleBreathingEffect.cpp" + "src/effects/cabin/NaturalHeadMovementEffect.cpp" "src/effects/driving/BodyDynamicsEffect.cpp" "src/effects/driving/HeadMotionEffect.cpp" "src/effects/driving/SteeringCameraEffect.cpp" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67a5490..5a5b720 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,10 @@ game's `plugins/spfPlugins/MotionCab/`. - If an effect only makes sense in the driver's seat, override `NeedsDriverSeat()` so it fades out while Cabin Walk has the player elsewhere in the cabin. +- If an effect shouldn't run alongside another one (e.g. Natural Head + Movement and Idle Breathing), declare the pair in `conflicts::kPauses` + (`src/core/Conflicts.hpp`): while the first is enabled, the second fades + out, its settings untouched, and the Quick Settings window says so. - Declare a new keybind action once in `src/core/Keybinds.hpp` (default key and localization key) and add it to `kAllActions`, plus `kPolledActions` if the effect polls it. diff --git a/README.md b/README.md index 7916064..554a21d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ camera control, keybind rebinding, and the in-game settings UI. braking - **Engine Start/Stop** — mechanical shudder when the engine catches or dies +- **Natural Head Movement** — TrackIR-like head movement, without a + tracker - **Manual Zoom** — smooth zoom effect - **Blindspot Viewer** — leans forward to see traffic lights hidden by the cab - **Cabin Walk** — walks around the parked cabin and sits in the passenger @@ -165,6 +167,12 @@ Values are persisted by the framework to `plugins/spfPlugins/MotionCab/config/se | **Engine Start/Stop** | | | | `intensity` | 0.1 | How strong the shudder feels when the engine starts or stops. | | `duration` | 1.0 | How long the start-up shudder lasts. The stop shudder is shorter and gentler than this. | +| **Natural Head Movement** | | | +| `tremor_intensity` | 5.0 | How much your head trembles slightly, all the time. | +| `micro_intensity` | 2.5 | How far your head moves when it makes a small adjustment. | +| `posture_intensity` | 1.3 | How far your head shifts when it settles into a new position: tilted, leaning forward or to one side. | +| `posture_interval` | 15.0 | About how long your head holds a position before settling into a new one. | +| `steering_tilt` | 1.5 | How much your head tilts into the turn as you turn the steering wheel. | | **Manual Zoom** | | | | `zoom_fov_deg` | 40.0 | How zoomed in the view gets while holding the zoom key. Lower = more zoomed in. | | `smoothing_time` | 0.25 | How smooth the zoom in/out feels. | @@ -213,6 +221,7 @@ game's `plugins/spfPlugins/MotionCab/`. │ │ ├── PluginContext.cpp / .hpp Shared plugin state │ │ ├── Profiles.cpp / .hpp Create/save/switch named presets │ │ ├── Settings.cpp / .hpp Every setting: key, default, slider range +│ │ ├── Conflicts.hpp Which effects pause which while enabled │ │ ├── Loc.cpp / .hpp Translated string lookup (loc::Tr) │ │ ├── Keybinds.hpp Keybind action names and key polling helpers │ │ ├── Strings.hpp Case-insensitive string helpers @@ -221,7 +230,7 @@ game's `plugins/spfPlugins/MotionCab/`. │ │ ├── SpringDamper.hpp Critically-damped spring, the core smoothing primitive │ │ ├── Noise.hpp Deterministic gradient noise │ │ ├── Oscillator.hpp Damped spring that can overshoot and ring -│ │ └── Units.hpp Unit conversions, SmoothStep, phase wrapping +│ │ └── Units.hpp Unit conversions, easing, Lerp, phase wrapping │ ├── effects/ │ │ ├── Effect.hpp Base effect interface + HeadOffset struct │ │ ├── ConfigurableEffect.hpp Base for effects: enabled toggle + settings access @@ -230,7 +239,8 @@ game's `plugins/spfPlugins/MotionCab/`. │ │ ├── driving/ HeadMotion, BodyDynamics, SteeringCamera │ │ ├── road/ Suspension, RoadIrregularity, SpeedShake, │ │ │ Surface (ground classification) -│ │ ├── cabin/ IdleBreathing, EngineVibration, EngineStartStop +│ │ ├── cabin/ IdleBreathing, EngineVibration, EngineStartStop, +│ │ │ NaturalHeadMovement │ │ └── manual/ MirrorCheck, ManualLook, ManualZoom, BlindspotViewer │ │ └── cabin_walk/ │ │ ├── CabinWalkEffect.* The effect: standing up, walking, sitting down diff --git a/localization/cs.json b/localization/cs.json index 696e10a..f863277 100644 --- a/localization/cs.json +++ b/localization/cs.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Zapne nebo vypne tento efekt.", + "paused_by": "Pozastaveno efektem: {effects}.", + "pauses": "Po zapnutí pozastaví následující efekt(y):", "driving": { "title": "Jízda", "head_motion": { @@ -235,6 +237,29 @@ "title": "Doba trvání", "desc": "Jak dlouho trvá škubnutí při startu. Škubnutí při vypnutí je kratší a jemnější." } + }, + "natural_head_movement": { + "title": "Přirozený pohyb hlavy", + "tremor_intensity": { + "title": "Chvění", + "desc": "Jak moc se hlava neustále lehce chvěje." + }, + "micro_intensity": { + "title": "Mikropohyby", + "desc": "Jak daleko se hlava pohne při drobné korekci." + }, + "posture_intensity": { + "title": "Změny držení", + "desc": "Jak daleko se hlava posune, když zaujme novou polohu: naklonění, předklon nebo posun do strany." + }, + "posture_interval": { + "title": "Interval držení", + "desc": "Přibližně jak dlouho hlava drží jednu polohu, než zaujme novou." + }, + "steering_tilt": { + "title": "Náklon při zatáčení", + "desc": "Jak moc se hlava nakloní do zatáčky, když otáčíte volantem." + } } }, "manual": { diff --git a/localization/de.json b/localization/de.json index eda8775..7cadc86 100644 --- a/localization/de.json +++ b/localization/de.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Schaltet diesen Effekt ein oder aus.", + "paused_by": "Pausiert durch {effects}.", + "pauses": "Wenn aktiviert, pausiert dies folgende(n) Effekt(e):", "driving": { "title": "Fahren", "head_motion": { @@ -235,6 +237,29 @@ "title": "Dauer", "desc": "Wie lange das Rütteln beim Start dauert. Das Rütteln beim Abstellen ist kürzer und sanfter." } + }, + "natural_head_movement": { + "title": "Natürliche Kopfbewegung", + "tremor_intensity": { + "title": "Zittern", + "desc": "Wie stark dein Kopf ständig leicht zittert." + }, + "micro_intensity": { + "title": "Mikrobewegungen", + "desc": "Wie weit sich dein Kopf bei einer kleinen Korrektur bewegt." + }, + "posture_intensity": { + "title": "Haltungswechsel", + "desc": "Wie weit sich dein Kopf verschiebt, wenn er eine neue Haltung einnimmt: geneigt, nach vorn oder zur Seite gelehnt." + }, + "posture_interval": { + "title": "Haltungsintervall", + "desc": "Wie lange dein Kopf ungefähr eine Haltung hält, bevor er eine neue einnimmt." + }, + "steering_tilt": { + "title": "Neigung beim Lenken", + "desc": "Wie stark sich dein Kopf beim Lenken in die Kurve neigt." + } } }, "manual": { diff --git a/localization/en.json b/localization/en.json index 25e1c24..d2bd21b 100644 --- a/localization/en.json +++ b/localization/en.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Turn this effect on or off.", + "paused_by": "Paused by {effects}.", + "pauses": "When enabled, this pauses the following effect(s):", "driving": { "title": "Driving", "head_motion": { @@ -235,6 +237,29 @@ "title": "Duration", "desc": "How long the start-up shudder lasts. The stop shudder is shorter and gentler than this." } + }, + "natural_head_movement": { + "title": "Natural Head Movement", + "tremor_intensity": { + "title": "Tremor", + "desc": "How much your head trembles slightly, all the time." + }, + "micro_intensity": { + "title": "Micro-Adjustments", + "desc": "How far your head moves when it makes a small adjustment." + }, + "posture_intensity": { + "title": "Posture Changes", + "desc": "How far your head shifts when it settles into a new position: tilted, leaning forward or to one side." + }, + "posture_interval": { + "title": "Posture Interval", + "desc": "About how long your head holds a position before settling into a new one." + }, + "steering_tilt": { + "title": "Steering Tilt", + "desc": "How much your head tilts into the turn as you turn the steering wheel." + } } }, "manual": { diff --git a/localization/es.json b/localization/es.json index 5d6d278..44245c0 100644 --- a/localization/es.json +++ b/localization/es.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Activa o desactiva este efecto.", + "paused_by": "En pausa por {effects}.", + "pauses": "Al activarlo, pausa el/los siguiente(s) efecto(s):", "driving": { "title": "Conducción", "head_motion": { @@ -235,6 +237,29 @@ "title": "Duración", "desc": "Cuánto dura la sacudida de arranque. La de parada es más corta y suave." } + }, + "natural_head_movement": { + "title": "Movimiento natural de cabeza", + "tremor_intensity": { + "title": "Temblor", + "desc": "Cuánto tiembla ligeramente tu cabeza, todo el tiempo." + }, + "micro_intensity": { + "title": "Microajustes", + "desc": "Cuánto se mueve tu cabeza al hacer un pequeño ajuste." + }, + "posture_intensity": { + "title": "Cambios de postura", + "desc": "Cuánto se desplaza tu cabeza al adoptar una nueva posición: inclinada, hacia delante o hacia un lado." + }, + "posture_interval": { + "title": "Intervalo de postura", + "desc": "Cuánto tiempo aproximadamente mantiene tu cabeza una posición antes de adoptar otra." + }, + "steering_tilt": { + "title": "Inclinación al girar", + "desc": "Cuánto se inclina tu cabeza hacia el interior de la curva al girar el volante." + } } }, "manual": { diff --git a/localization/fr.json b/localization/fr.json index ae69598..353dacb 100644 --- a/localization/fr.json +++ b/localization/fr.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Active ou désactive cet effet.", + "paused_by": "Mis en pause par {effects}.", + "pauses": "Une fois activé, cet effet met en pause le(s) effet(s) suivant(s) :", "driving": { "title": "Conduite", "head_motion": { @@ -235,6 +237,29 @@ "title": "Durée", "desc": "Durée de la secousse au démarrage. Celle de l'arrêt est plus courte et plus douce." } + }, + "natural_head_movement": { + "title": "Mouvement de tête naturel", + "tremor_intensity": { + "title": "Tremblement", + "desc": "À quel point votre tête tremble légèrement, en permanence." + }, + "micro_intensity": { + "title": "Micro-ajustements", + "desc": "À quel point votre tête bouge lors d'un petit ajustement." + }, + "posture_intensity": { + "title": "Changements de posture", + "desc": "À quel point votre tête se déplace quand elle prend une nouvelle position : penchée, avancée ou décalée sur un côté." + }, + "posture_interval": { + "title": "Intervalle de posture", + "desc": "Combien de temps environ votre tête garde une position avant d'en prendre une nouvelle." + }, + "steering_tilt": { + "title": "Inclinaison au volant", + "desc": "À quel point votre tête s'incline vers l'intérieur du virage quand vous tournez le volant." + } } }, "manual": { diff --git a/localization/it.json b/localization/it.json index ae7d7da..a94e9dd 100644 --- a/localization/it.json +++ b/localization/it.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Attiva o disattiva questo effetto.", + "paused_by": "In pausa a causa di {effects}.", + "pauses": "Se attivo, mette in pausa i seguenti effetti:", "driving": { "title": "Guida", "head_motion": { @@ -235,6 +237,29 @@ "title": "Durata", "desc": "Quanto dura lo scossone all'avvio. Quello all'arresto è più breve e più dolce." } + }, + "natural_head_movement": { + "title": "Movimento naturale della testa", + "tremor_intensity": { + "title": "Tremore", + "desc": "Quanto la tua testa trema leggermente, in ogni momento." + }, + "micro_intensity": { + "title": "Microaggiustamenti", + "desc": "Quanto si muove la tua testa durante un piccolo aggiustamento." + }, + "posture_intensity": { + "title": "Cambi di postura", + "desc": "Quanto si sposta la tua testa quando assume una nuova posizione: inclinata, in avanti o di lato." + }, + "posture_interval": { + "title": "Intervallo di postura", + "desc": "Per quanto tempo circa la tua testa mantiene una posizione prima di assumerne una nuova." + }, + "steering_tilt": { + "title": "Inclinazione in sterzata", + "desc": "Quanto la tua testa si inclina verso l'interno della curva quando giri il volante." + } } }, "manual": { diff --git a/localization/ja.json b/localization/ja.json index e86210f..d17de8a 100644 --- a/localization/ja.json +++ b/localization/ja.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "このエフェクトのオン/オフを切り替えます。", + "paused_by": "{effects}により一時停止中です。", + "pauses": "オンにすると、次のエフェクトを一時停止します:", "driving": { "title": "運転", "head_motion": { @@ -235,6 +237,29 @@ "title": "長さ", "desc": "始動時の揺れが続く時間。停止時の揺れはこれより短く穏やかです。" } + }, + "natural_head_movement": { + "title": "自然な頭の動き", + "tremor_intensity": { + "title": "微振動", + "desc": "頭が常にわずかに揺れる量です。" + }, + "micro_intensity": { + "title": "微調整", + "desc": "頭が小さく調整するときの動きの大きさです。" + }, + "posture_intensity": { + "title": "姿勢の変化", + "desc": "頭が新しい姿勢に移るときの動きの大きさです(傾ける、前に乗り出す、横に寄るなど)。" + }, + "posture_interval": { + "title": "姿勢の間隔", + "desc": "頭が新しい姿勢に移るまで、ひとつの姿勢を保つおおよその時間です。" + }, + "steering_tilt": { + "title": "ハンドル操作時の傾き", + "desc": "ハンドルを切ったときに頭がカーブの内側へ傾く量です。" + } } }, "manual": { diff --git a/localization/ko.json b/localization/ko.json index 056240d..86f75eb 100644 --- a/localization/ko.json +++ b/localization/ko.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "이 효과를 켜거나 끕니다.", + "paused_by": "{effects}에 의해 일시 정지되었습니다.", + "pauses": "켜면 다음 효과를 일시 정지합니다:", "driving": { "title": "주행", "head_motion": { @@ -235,6 +237,29 @@ "title": "지속 시간", "desc": "시동 떨림이 지속되는 시간입니다. 정지 떨림은 이보다 짧고 부드럽습니다." } + }, + "natural_head_movement": { + "title": "자연스러운 머리 움직임", + "tremor_intensity": { + "title": "떨림", + "desc": "머리가 항상 살짝 떨리는 정도입니다." + }, + "micro_intensity": { + "title": "미세 조정", + "desc": "머리가 작게 조정할 때 움직이는 정도입니다." + }, + "posture_intensity": { + "title": "자세 변화", + "desc": "머리가 새 자세로 바뀔 때 움직이는 정도입니다: 기울이기, 앞으로 숙이기, 한쪽으로 기대기." + }, + "posture_interval": { + "title": "자세 간격", + "desc": "머리가 새 자세로 바꾸기 전에 한 자세를 유지하는 대략적인 시간입니다." + }, + "steering_tilt": { + "title": "핸들 기울기", + "desc": "핸들을 돌릴 때 머리가 커브 안쪽으로 기우는 정도입니다." + } } }, "manual": { diff --git a/localization/nl.json b/localization/nl.json index 6657049..5b9158e 100644 --- a/localization/nl.json +++ b/localization/nl.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Zet dit effect aan of uit.", + "paused_by": "Gepauzeerd door {effects}.", + "pauses": "Als dit aan staat, pauzeert het de volgende effect(en):", "driving": { "title": "Rijden", "head_motion": { @@ -235,6 +237,29 @@ "title": "Duur", "desc": "Hoe lang de startschok duurt. De stopschok is korter en zachter." } + }, + "natural_head_movement": { + "title": "Natuurlijke hoofdbeweging", + "tremor_intensity": { + "title": "Trilling", + "desc": "Hoeveel je hoofd voortdurend licht trilt." + }, + "micro_intensity": { + "title": "Microbewegingen", + "desc": "Hoe ver je hoofd beweegt bij een kleine correctie." + }, + "posture_intensity": { + "title": "Houdingswissels", + "desc": "Hoe ver je hoofd verschuift als het een nieuwe houding aanneemt: gekanteld, naar voren of opzij geleund." + }, + "posture_interval": { + "title": "Houdingsinterval", + "desc": "Hoe lang je hoofd ongeveer een houding vasthoudt voordat het een nieuwe aanneemt." + }, + "steering_tilt": { + "title": "Kanteling bij sturen", + "desc": "Hoeveel je hoofd de bocht in kantelt als je aan het stuur draait." + } } }, "manual": { diff --git a/localization/pl.json b/localization/pl.json index bc0a7e9..0309aac 100644 --- a/localization/pl.json +++ b/localization/pl.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Włącza lub wyłącza ten efekt.", + "paused_by": "Wstrzymane przez: {effects}.", + "pauses": "Po włączeniu wstrzymuje następujące efekty:", "driving": { "title": "Jazda", "head_motion": { @@ -235,6 +237,29 @@ "title": "Czas trwania", "desc": "Jak długo trwa szarpnięcie przy starcie. Szarpnięcie przy gaszeniu jest krótsze i łagodniejsze." } + }, + "natural_head_movement": { + "title": "Naturalny ruch głowy", + "tremor_intensity": { + "title": "Drżenie", + "desc": "Jak bardzo głowa stale lekko drży." + }, + "micro_intensity": { + "title": "Mikroruchy", + "desc": "Jak daleko porusza się głowa przy drobnej korekcie." + }, + "posture_intensity": { + "title": "Zmiany postawy", + "desc": "Jak daleko przesuwa się głowa, gdy przyjmuje nową pozycję: przechyloną, pochyloną do przodu lub na bok." + }, + "posture_interval": { + "title": "Odstęp zmian postawy", + "desc": "Mniej więcej jak długo głowa utrzymuje pozycję, zanim przyjmie nową." + }, + "steering_tilt": { + "title": "Przechył przy skręcie", + "desc": "Jak bardzo głowa przechyla się do wnętrza zakrętu, gdy kręcisz kierownicą." + } } }, "manual": { diff --git a/localization/pt_br.json b/localization/pt_br.json index 7727138..392c10b 100644 --- a/localization/pt_br.json +++ b/localization/pt_br.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Liga ou desliga este efeito.", + "paused_by": "Pausado por {effects}.", + "pauses": "Quando ativado, pausa o(s) seguinte(s) efeito(s):", "driving": { "title": "Direção", "head_motion": { @@ -235,6 +237,29 @@ "title": "Duração", "desc": "Quanto dura o tranco da partida. O da parada é mais curto e suave." } + }, + "natural_head_movement": { + "title": "Movimento natural da cabeça", + "tremor_intensity": { + "title": "Tremor", + "desc": "O quanto sua cabeça treme levemente, o tempo todo." + }, + "micro_intensity": { + "title": "Microajustes", + "desc": "O quanto sua cabeça se move ao fazer um pequeno ajuste." + }, + "posture_intensity": { + "title": "Mudanças de postura", + "desc": "O quanto sua cabeça se desloca ao assumir uma nova posição: inclinada, para a frente ou para um lado." + }, + "posture_interval": { + "title": "Intervalo de postura", + "desc": "Por quanto tempo, aproximadamente, sua cabeça mantém uma posição antes de assumir outra." + }, + "steering_tilt": { + "title": "Inclinação ao esterçar", + "desc": "O quanto sua cabeça se inclina para dentro da curva ao girar o volante." + } } }, "manual": { diff --git a/localization/ru.json b/localization/ru.json index a8516e0..585a149 100644 --- a/localization/ru.json +++ b/localization/ru.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Включает или выключает этот эффект.", + "paused_by": "Приостановлено эффектом: {effects}.", + "pauses": "При включении приостанавливает следующие эффекты:", "driving": { "title": "Вождение", "head_motion": { @@ -235,6 +237,29 @@ "title": "Длительность", "desc": "Сколько длится толчок при запуске. Толчок при остановке короче и мягче." } + }, + "natural_head_movement": { + "title": "Естественное движение головы", + "tremor_intensity": { + "title": "Дрожание", + "desc": "Насколько голова постоянно слегка дрожит." + }, + "micro_intensity": { + "title": "Микродвижения", + "desc": "Насколько сдвигается голова при небольшой поправке." + }, + "posture_intensity": { + "title": "Смена позы", + "desc": "Насколько смещается голова, принимая новое положение: наклон вбок, вперёд или сдвиг в сторону." + }, + "posture_interval": { + "title": "Интервал смены позы", + "desc": "Примерно сколько времени голова держит положение, прежде чем принять новое." + }, + "steering_tilt": { + "title": "Наклон при повороте руля", + "desc": "Насколько голова наклоняется внутрь поворота, когда вы поворачиваете руль." + } } }, "manual": { diff --git a/localization/tr.json b/localization/tr.json index ac57141..aef09e5 100644 --- a/localization/tr.json +++ b/localization/tr.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Bu efekti açar veya kapatır.", + "paused_by": "{effects} tarafından duraklatıldı.", + "pauses": "Açıldığında şu efekt(ler)i duraklatır:", "driving": { "title": "Sürüş", "head_motion": { @@ -235,6 +237,29 @@ "title": "Süre", "desc": "Çalıştırma sarsıntısının ne kadar sürdüğü. Durdurma sarsıntısı bundan daha kısa ve yumuşaktır." } + }, + "natural_head_movement": { + "title": "Doğal Baş Hareketi", + "tremor_intensity": { + "title": "Titreme", + "desc": "Başınızın sürekli ne kadar hafifçe titrediği." + }, + "micro_intensity": { + "title": "Mikro Ayarlar", + "desc": "Küçük bir ayarlamada başınızın ne kadar hareket ettiği." + }, + "posture_intensity": { + "title": "Duruş Değişimleri", + "desc": "Başınızın yeni bir pozisyona geçerken ne kadar kaydığı: eğik, öne veya bir yana yaslanmış." + }, + "posture_interval": { + "title": "Duruş Aralığı", + "desc": "Başınızın yeni bir pozisyona geçmeden önce bir pozisyonu yaklaşık ne kadar koruduğu." + }, + "steering_tilt": { + "title": "Direksiyon Eğimi", + "desc": "Direksiyonu çevirdiğinizde başınızın virajın içine ne kadar eğildiği." + } } }, "manual": { diff --git a/localization/uk.json b/localization/uk.json index f4c73f5..66ec3b7 100644 --- a/localization/uk.json +++ b/localization/uk.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "Вмикає або вимикає цей ефект.", + "paused_by": "Призупинено ефектом: {effects}.", + "pauses": "Коли ввімкнено, призупиняє такі ефекти:", "driving": { "title": "Водіння", "head_motion": { @@ -235,6 +237,29 @@ "title": "Тривалість", "desc": "Скільки триває поштовх під час запуску. Поштовх під час зупинки коротший і м'якший." } + }, + "natural_head_movement": { + "title": "Природний рух голови", + "tremor_intensity": { + "title": "Тремтіння", + "desc": "Наскільки голова постійно злегка тремтить." + }, + "micro_intensity": { + "title": "Мікрорухи", + "desc": "Наскільки зміщується голова під час невеликої поправки." + }, + "posture_intensity": { + "title": "Зміна пози", + "desc": "Наскільки зміщується голова, приймаючи нове положення: нахил убік, уперед або зсув у бік." + }, + "posture_interval": { + "title": "Інтервал зміни пози", + "desc": "Приблизно скільки часу голова тримає положення, перш ніж прийняти нове." + }, + "steering_tilt": { + "title": "Нахил під час керма", + "desc": "Наскільки голова нахиляється всередину повороту, коли ви повертаєте кермо." + } } }, "manual": { diff --git a/localization/zh.json b/localization/zh.json index d01f217..be5182c 100644 --- a/localization/zh.json +++ b/localization/zh.json @@ -81,6 +81,8 @@ }, "settings": { "enabled_desc": "开启或关闭此效果。", + "paused_by": "已被{effects}暂停。", + "pauses": "启用后,将暂停以下效果:", "driving": { "title": "驾驶", "head_motion": { @@ -235,6 +237,29 @@ "title": "持续时间", "desc": "启动抖动持续的时间。熄火抖动更短、更柔和。" } + }, + "natural_head_movement": { + "title": "自然头部运动", + "tremor_intensity": { + "title": "颤动", + "desc": "头部始终轻微颤动的程度。" + }, + "micro_intensity": { + "title": "微调", + "desc": "头部做小幅调整时移动的幅度。" + }, + "posture_intensity": { + "title": "姿势变化", + "desc": "头部换到新姿势时的移动幅度:歪头、前倾或偏向一侧。" + }, + "posture_interval": { + "title": "姿势间隔", + "desc": "头部保持一个姿势大约多久后再换到新姿势。" + }, + "steering_tilt": { + "title": "转向倾斜", + "desc": "转动方向盘时头部向弯道内侧倾斜的幅度。" + } } }, "manual": { diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 7ca9030..80ab0ee 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -15,6 +15,7 @@ #include "effects/cabin/EngineStartStopEffect.hpp" #include "effects/cabin/EngineVibrationEffect.hpp" #include "effects/cabin/IdleBreathingEffect.hpp" +#include "effects/cabin/NaturalHeadMovementEffect.hpp" #include "effects/driving/BodyDynamicsEffect.hpp" #include "effects/driving/HeadMotionEffect.hpp" #include "effects/driving/SteeringCameraEffect.hpp" @@ -237,6 +238,8 @@ void OnActivated(const SPF_Core_API *core_api) { std::make_unique(core_api->config, ctx.config_handle)); ctx.effects.Register(std::make_unique( core_api->config, ctx.config_handle)); + ctx.effects.Register(std::make_unique( + core_api->config, ctx.config_handle)); ctx.effects.Register(std::make_unique(core_api->config, ctx.config_handle)); ctx.effects.Register(std::make_unique( diff --git a/src/core/Conflicts.hpp b/src/core/Conflicts.hpp new file mode 100644 index 0000000..1767440 --- /dev/null +++ b/src/core/Conflicts.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "core/Settings.hpp" + +#include + +// Which effects pause which, declared once: the EffectManager fades a +// paused effect out while an effect pausing it is enabled (its settings +// left alone), and the Quick Settings window says so in both sections. +// Effects go by their settings prefix, "settings..". +namespace motioncab::conflicts { + +struct Pause { + const char *effect; // pauses... + const char *paused; // ...this one while enabled +}; + +inline constexpr Pause kPauses[] = { + // {"if this effect is enabled (active)", "then pause this one"}, + {"settings.cabin.natural_head_movement", "settings.road.speed_shake"}, + {"settings.cabin.natural_head_movement", "settings.cabin.idle_breathing"}, +}; + +// True if `effect` pauses `paused`. +constexpr bool Pauses(std::string_view effect, std::string_view paused) { + for (const Pause &p : kPauses) { + if (effect == p.effect && paused == p.paused) + return true; + } + return false; +} + +// Every effect named in kPauses has its "enabled" toggle in settings::kAll, +// and none pauses itself. +consteval bool PausesAreValid() { + const auto has_toggle = [](std::string_view prefix) { + for (const settings::Setting &s : settings::kAll) { + if (settings::InEffect(s, prefix) && s.type == settings::Type::kBool && + settings::SplitKey(s.key).name == "enabled") + return true; + } + return false; + }; + for (const Pause &p : kPauses) { + if (!has_toggle(p.effect) || !has_toggle(p.paused) || + std::string_view(p.effect) == p.paused) + return false; + } + return true; +} +static_assert(PausesAreValid(), + "kPauses names an effect without an \"enabled\" setting"); + +} // namespace motioncab::conflicts diff --git a/src/core/Settings.hpp b/src/core/Settings.hpp index 580016f..1e52b05 100644 --- a/src/core/Settings.hpp +++ b/src/core/Settings.hpp @@ -113,6 +113,18 @@ inline constexpr Setting kAll[] = { "%.2f"), Float("settings.cabin.engine_start_stop.duration", 1.0f, 0.3f, 2.0f, "%.2f s"), + // cabin.natural_head_movement + Bool("settings.cabin.natural_head_movement.enabled", false), + Float("settings.cabin.natural_head_movement.tremor_intensity", 5.0f, 0.0f, + 8.0f, "%.2f"), + Float("settings.cabin.natural_head_movement.micro_intensity", 2.5f, 0.0f, + 5.0f, "%.2f"), + Float("settings.cabin.natural_head_movement.posture_intensity", 1.3f, 0.0f, + 2.0f, "%.2f"), + Float("settings.cabin.natural_head_movement.posture_interval", 15.0f, 5.0f, + 60.0f, "%.0f s"), + Float("settings.cabin.natural_head_movement.steering_tilt", 1.5f, 0.0f, + 3.0f, "%.2f"), // manual.mirror_check Bool("settings.manual.mirror_check.enabled", true), Float("settings.manual.mirror_check.look_angle_deg", 28.0f, 15.0f, 50.0f, diff --git a/src/effects/ConfigurableEffect.hpp b/src/effects/ConfigurableEffect.hpp index f533913..3ec000f 100644 --- a/src/effects/ConfigurableEffect.hpp +++ b/src/effects/ConfigurableEffect.hpp @@ -43,6 +43,7 @@ class ConfigurableEffect : public Effect { public: bool IsEnabled() const override { return enabled_; } void SetEnabled(bool enabled) override { enabled_ = enabled; } + std::string_view Id() const override { return id_; } void LoadConfig() final { if (!config_.IsAvailable()) @@ -55,7 +56,8 @@ class ConfigurableEffect : public Effect { ConfigurableEffect(SPF_Config_API *config_api, SPF_Config_Handle *config_handle, const char *group, const char *name) - : config_(config_api, config_handle, group, name) {} + : config_(config_api, config_handle, group, name), + id_(std::string("settings.") + group + "." + name) {} // Reads the effect's own tunables (everything but "enabled") with Float() // and Bool(), and applies them, e.g. to its springs' time constants. @@ -70,6 +72,7 @@ class ConfigurableEffect : public Effect { private: EffectConfig config_; + std::string id_; bool enabled_ = true; }; diff --git a/src/effects/Effect.hpp b/src/effects/Effect.hpp index e0cb53d..0b9ca62 100644 --- a/src/effects/Effect.hpp +++ b/src/effects/Effect.hpp @@ -2,6 +2,8 @@ #include "SPF_TelemetryData.h" +#include + namespace motioncab { // Additive offset an effect wants applied to the interior head/seat pose. @@ -46,6 +48,10 @@ class Effect { // (EffectManager::SetAtWheel). virtual bool NeedsDriverSeat() const { return false; } + // The effect's settings prefix, "settings..": what + // conflicts::kPauses names it by. + virtual std::string_view Id() const = 0; + // Called whenever the truck's static configuration changes (bought a new // truck, added/removed axles). Default no-op; only effects that need // per-wheel layout (e.g. front/rear classification) override this. diff --git a/src/effects/EffectManager.cpp b/src/effects/EffectManager.cpp index 51944e6..abf516b 100644 --- a/src/effects/EffectManager.cpp +++ b/src/effects/EffectManager.cpp @@ -1,5 +1,6 @@ #include "EffectManager.hpp" +#include "core/Conflicts.hpp" #include "math/Units.hpp" #include @@ -8,6 +9,14 @@ namespace motioncab { void EffectManager::Register(std::unique_ptr effect) { effects_.push_back({std::move(effect)}); + // Relink every effect: the new one can pause, or be paused by, any other. + for (size_t i = 0; i < effects_.size(); ++i) { + effects_[i].paused_by.clear(); + for (size_t j = 0; j < effects_.size(); ++j) { + if (conflicts::Pauses(effects_[j].effect->Id(), effects_[i].effect->Id())) + effects_[i].paused_by.push_back(j); + } + } } void EffectManager::LoadAllConfig() { @@ -20,10 +29,21 @@ void EffectManager::ResetAll() { slot.effect->Reset(); slot.fade = slot.effect->IsEnabled() ? 1.0f : 0.0f; slot.seat_reset = false; + slot.pause_fade = IsPaused(slot) ? 0.0f : 1.0f; + slot.pause_reset = false; } seat_fade_ = at_wheel_ ? 1.0f : 0.0f; } +bool EffectManager::IsPaused(const Slot &slot) const { + // Only while the pausing effect actually plays: one that needs the + // driver's seat lets the others back while Cabin Walk is away. + return std::ranges::any_of(slot.paused_by, [this](size_t i) { + const Effect &pauser = *effects_[i].effect; + return pauser.IsEnabled() && (at_wheel_ || !pauser.NeedsDriverSeat()); + }); +} + HeadOffset EffectManager::UpdateAndAccumulate(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) { @@ -33,7 +53,21 @@ HeadOffset EffectManager::UpdateAndAccumulate(float dt, HeadOffset total; for (auto &slot : effects_) { Effect &effect = *slot.effect; - float seat_weight = 1.0f; + // The toggle's fade moves even while the seat or a pause holds the + // effect back, so it never picks up again from a stale value (an effect + // turned off while paused would play a moment once unpaused). + if (effect.IsEnabled()) { + slot.fade = std::min(1.0f, slot.fade + step); + } else if (slot.fade > 0.0f) { + slot.fade = std::max(0.0f, slot.fade - step); + if (slot.fade <= 0.0f) + effect.Reset(); // start from rest once re-enabled + } + slot.pause_fade = IsPaused(slot) ? std::max(0.0f, slot.pause_fade - step) + : std::min(1.0f, slot.pause_fade + step); + if (slot.fade <= 0.0f) + continue; + float context_weight = 1.0f; if (effect.NeedsDriverSeat()) { if (seat_fade_ <= 0.0f) { if (!slot.seat_reset) @@ -42,21 +76,18 @@ HeadOffset EffectManager::UpdateAndAccumulate(float dt, continue; } slot.seat_reset = false; - seat_weight = math::SmoothStep(seat_fade_); + context_weight = math::SmoothStep(seat_fade_); } - if (effect.IsEnabled()) { - slot.fade = std::min(1.0f, slot.fade + step); - } else { - if (slot.fade <= 0.0f) - continue; - slot.fade = std::max(0.0f, slot.fade - step); - if (slot.fade <= 0.0f) { - effect.Reset(); // start from rest once re-enabled - continue; - } + if (slot.pause_fade <= 0.0f) { + if (!slot.pause_reset) + effect.Reset(); // start from rest once no longer paused + slot.pause_reset = true; + continue; } + slot.pause_reset = false; + context_weight *= math::SmoothStep(slot.pause_fade); const HeadOffset contribution = effect.Update(dt, truck, controls); - const float weight = math::SmoothStep(slot.fade) * seat_weight; + const float weight = math::SmoothStep(slot.fade) * context_weight; total.pos_x += contribution.pos_x * weight; total.pos_y += contribution.pos_y * weight; total.pos_z += contribution.pos_z * weight; diff --git a/src/effects/EffectManager.hpp b/src/effects/EffectManager.hpp index f4e61ca..89c9f54 100644 --- a/src/effects/EffectManager.hpp +++ b/src/effects/EffectManager.hpp @@ -15,6 +15,9 @@ namespace motioncab { // Camera in a turn) would otherwise snap the camera. A disabled effect // keeps being updated until it has faded out, then is Reset() so it starts // from rest once re-enabled. +// +// An effect paused by another (conflicts::kPauses) fades out the same way +// while that one plays, and is Reset() once faded out. class EffectManager { public: void Register(std::unique_ptr effect); @@ -52,7 +55,16 @@ class EffectManager { std::unique_ptr effect; float fade = 1.0f; // 0 = contributes nothing, 1 = full contribution bool seat_reset = false; // Reset() since the driver's seat was left + // The effects that pause this one (conflicts::kPauses), by index. + std::vector paused_by; + float pause_fade = 1.0f; // same as fade, 0 while paused + bool pause_reset = false; // Reset() since paused }; + + // Whether an effect pausing `slot` plays: enabled, and in the driver's + // seat if it needs it. + bool IsPaused(const Slot &slot) const; + std::vector effects_; bool at_wheel_ = true; float seat_fade_ = 1.0f; // same as Slot::fade, for NeedsDriverSeat effects diff --git a/src/effects/cabin/NaturalHeadMovementEffect.cpp b/src/effects/cabin/NaturalHeadMovementEffect.cpp new file mode 100644 index 0000000..46b9307 --- /dev/null +++ b/src/effects/cabin/NaturalHeadMovementEffect.cpp @@ -0,0 +1,289 @@ +#include "NaturalHeadMovementEffect.hpp" + +#include "effects/Telemetry.hpp" +#include "math/Noise.hpp" +#include "math/Units.hpp" + +#include +#include +#include +#include + +namespace motioncab { + +namespace { +// Per axis below: X, Y, Z in meters, then yaw, pitch, roll in degrees, at +// intensity 1. The neck pivot adds its own translation on top. + +// Noise cells per second of the tremor's two octaves, and their weights. +constexpr double kTremorRate[] = {0.15, 0.4}; +constexpr float kOctaveWeight[] = {1.0f, 0.35f}; +// Brings the octaves' sum back to about [-1, 1]. +constexpr float kTremorNorm = 1.0f / (kOctaveWeight[0] + kOctaveWeight[1]); +constexpr float kTremorAmplitude[] = {0.002f, 0.0015f, 0.002f, + 0.35f, 0.25f, 0.2f}; + +// How far a micro-adjustment goes, how long one takes across its whole +// range, and the mean seconds between two. +constexpr float kMicroRange[] = {0.003f, 0.002f, 0.004f, 1.0f, 0.7f, 0.6f}; +constexpr float kMicroSeconds = 3.0f; +constexpr float kMicroInterval = 15.0f; + +// The farthest a posture goes at scale 1, and how long a change takes +// across its whole range. +constexpr float kPostureRange[] = {0.012f, 0.012f, 0.025f, 5.0f, 3.0f, 3.0f}; +constexpr float kPostureSeconds = 2.0f; +constexpr float kPostureVariation = 0.1f; + +// A move with no distance to go takes this share of the duration of one +// across the whole range (the farther, the longer). +constexpr float kShortMoveShare = 0.4f; + +// The postures, in -1..1 of kPostureRange per axis (X, Y, Z, yaw, pitch, +// roll), on one side: X, yaw and roll flip for the other. Drawn at 50 to +// 100% and varied by up to kPostureVariation on each axis. +using PostureShape = float[6]; +constexpr PostureShape kIdlePostures[] = { + {0.0f, -1.0f, 0.3f, 0.0f, -0.2f, 0.0f}, // slouched + {0.8f, -0.4f, 0.0f, -0.1f, 0.0f, 0.6f}, // leaning on the door + {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}, // head tilted + {0.0f, 0.2f, 0.0f, 0.0f, 0.6f, 1.0f}, // stretching the neck + {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.2f}, // looking out the window + {0.0f, -0.2f, -0.3f, 0.0f, -1.0f, 0.0f}, // head down, at the lap +}; +constexpr PostureShape kDrivingPostures[] = { + {0.0f, -0.2f, -1.0f, 0.0f, -0.3f, 0.0f}, // forward, toward the road + {0.0f, 0.6f, 0.0f, 0.0f, 0.2f, 0.0f}, // sitting up tall + {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f}, // head slightly tilted + {0.5f, -0.2f, 0.0f, 0.0f, 0.0f, 0.3f}, // leaning a little on one side +}; + +// How a mode draws its postures: from which shapes, how often the neutral +// one, how far (a scale of kPostureRange) and how slowly (a scale of +// kPostureSeconds). +struct PostureMode { + std::span shapes; + float neutral_odds; + float scale; + float slowness; +}; +// Stopped a while, relaxed: wider, slower, rarely back to neutral. +constexpr PostureMode kIdleMode{kIdlePostures, 0.2f, 1.5f, 1.3f}; +// Driving, focused on the road: small postures, often back to neutral. +constexpr PostureMode kDrivingMode{kDrivingPostures, 1.0f / 3.0f, 0.6f, 1.0f}; + +// The idle mode starts once stopped (under kIdleSpeedKmh) this long. The +// move into its first posture takes kRelaxSeconds, settling in; driving +// off, the move into the driving mode's takes kFocusSeconds. +constexpr float kIdleSpeedKmh = 2.0f; +constexpr float kIdleDelaySeconds = 5.0f; +constexpr float kRelaxSeconds = 3.0f; +constexpr float kFocusSeconds = 3.0f; + +// The tilt into the turn at full steering lock, in degrees. It follows the +// square root of the steering, so a lane correction tilts a little already +// without full lock tilting too much, eased over about this long. +constexpr float kSteeringTiltDeg = 3.0f; +constexpr float kSteeringTiltSeconds = 0.4f; + +// The neck the head turns on, from the eyes, in meters. +constexpr float kNeckBelow = 0.10f; +constexpr float kNeckBehind = 0.08f; + +// Each axis' tremor noise gets its own seed, so they don't move together. +constexpr uint32_t kTremorSeed = 0x4E484D31u; +constexpr uint32_t kAxisSeedStep = 0x9E3779B9u; +constexpr uint32_t kOctaveSeedStep = 0x85EBCA6Bu; + +// Where the eyes move when the head turns by these angles (radians) on the +// neck, in the head's frame (X to the driver's right, roll positive to the +// right, as seen in game). Rolled first, then pitched, then yawed, each +// about the eyes-to-neck lever. +struct Vec3 { + float x, y, z; +}; +Vec3 NeckPivot(float yaw, float pitch, float roll) { + // The eyes, from the neck: above and in front. + Vec3 e{0.0f, kNeckBelow, -kNeckBehind}; + // roll, about the forward axis + e = {e.y * std::sin(roll), e.y * std::cos(roll), e.z}; + // pitch, about the right axis (positive up) + e = {e.x, e.y * std::cos(pitch) - e.z * std::sin(pitch), + e.y * std::sin(pitch) + e.z * std::cos(pitch)}; + // yaw, about the up axis (positive left) + e = {e.x * std::cos(yaw) + e.z * std::sin(yaw), e.y, + -e.x * std::sin(yaw) + e.z * std::cos(yaw)}; + return {e.x, e.y - kNeckBelow, e.z + kNeckBehind}; +} +} // namespace + +float NaturalHeadMovementEffect::Layer::Value(int axis) const { + return math::Lerp(from[axis], to[axis], + math::SmootherStep(elapsed / duration)); +} + +void NaturalHeadMovementEffect::Layer::MoveTo(const Pose &target, + float min_seconds, + float max_seconds) { + float distance = 0.0f; + for (int a = 0; a < kAxes; ++a) { + from[a] = Value(a); + distance = std::max(distance, std::fabs(target[a] - from[a])); + } + to = target; + elapsed = 0.0f; + duration = math::Lerp(min_seconds, max_seconds, std::min(distance, 1.0f)); +} + +void NaturalHeadMovementEffect::Layer::Advance(float dt) { + elapsed = std::min(elapsed + dt, duration); +} + +void NaturalHeadMovementEffect::Layer::Reset() { + from.fill(0.0f); + to.fill(0.0f); + elapsed = duration; +} + +NaturalHeadMovementEffect::NaturalHeadMovementEffect( + SPF_Config_API *config_api, SPF_Config_Handle *config_handle) + : ConfigurableEffect(config_api, config_handle, "cabin", + "natural_head_movement"), + rng_(std::random_device{}()) { + steering_roll_.SetTimeConstant(kSteeringTiltSeconds); + Reset(); +} + +void NaturalHeadMovementEffect::LoadSettings() { + tremor_intensity_ = Float("tremor_intensity", tremor_intensity_); + micro_intensity_ = Float("micro_intensity", micro_intensity_); + posture_intensity_ = Float("posture_intensity", posture_intensity_); + posture_interval_ = Float("posture_interval", posture_interval_); + steering_tilt_ = Float("steering_tilt", steering_tilt_); +} + +void NaturalHeadMovementEffect::OnTruckConstantsChanged( + const SPF_TruckConstants &constants) { + right_hand_drive_ = telemetry::IsRightHandDrive(constants); +} + +void NaturalHeadMovementEffect::Reset() { + // The tremor's phases aren't reset: the head picks up somewhere new. + micro_.Reset(); + micro_.timer = Jitter(kMicroInterval); + posture_.Reset(); + posture_.timer = Jitter(posture_interval_); + stopped_for_ = 0.0f; + idle_ = false; + steering_roll_.Reset(); +} + +float NaturalHeadMovementEffect::Jitter(float mean) { + std::uniform_real_distribution jitter(0.5f, 1.5f); + return mean * jitter(rng_); +} + +void NaturalHeadMovementEffect::DrawMicro() { + std::uniform_real_distribution side(-1.0f, 1.0f); + Pose target; + for (float &axis : target) + axis = side(rng_); + micro_.MoveTo(target, kShortMoveShare * kMicroSeconds, kMicroSeconds); +} + +void NaturalHeadMovementEffect::DrawPosture() { + const PostureMode &mode = idle_ ? kIdleMode : kDrivingMode; + Pose target{}; + std::uniform_real_distribution unit(0.0f, 1.0f); + if (unit(rng_) >= mode.neutral_odds) { + std::uniform_int_distribution pick(0, mode.shapes.size() - 1); + const PostureShape &shape = mode.shapes[pick(rng_)]; + const float side = unit(rng_) < 0.5f ? -1.0f : 1.0f; + std::uniform_real_distribution strength(0.5f, 1.0f); + std::uniform_real_distribution vary(-kPostureVariation, + kPostureVariation); + const float amount = strength(rng_) * mode.scale; + for (int a = 0; a < kAxes; ++a) { + const bool lateral = a == kX || a == kYaw || a == kRoll; + target[a] = (lateral ? side : 1.0f) * shape[a] * amount + vary(rng_); + } + } + const float duration = kPostureSeconds * mode.slowness; + posture_.MoveTo(target, kShortMoveShare * duration, duration); + posture_.timer = Jitter(posture_interval_); +} + +HeadOffset NaturalHeadMovementEffect::Update(float dt, + const SPF_TruckData &truck, + const SPF_Controls &controls) { + for (size_t o = 0; o < tremor_phase_.size(); ++o) + tremor_phase_[o] = + math::WrapNoisePhase(tremor_phase_[o] + dt * kTremorRate[o]); + + // Stopped a while, the driver relaxes; driving off, they sit up and look + // at the road. A new posture of the new mode at once either way. + stopped_for_ = + telemetry::SpeedKmh(truck) < kIdleSpeedKmh ? stopped_for_ + dt : 0.0f; + const bool idle = stopped_for_ >= kIdleDelaySeconds; + if (idle != idle_) { + idle_ = idle; + DrawPosture(); + posture_.duration = idle ? kRelaxSeconds : kFocusSeconds; + } + + micro_.Advance(dt); + posture_.Advance(dt); + micro_.timer -= dt; + if (micro_.timer <= 0.0f) { + DrawMicro(); + micro_.timer = Jitter(kMicroInterval); + } + posture_.timer -= dt; + if (posture_.timer <= 0.0f) + DrawPosture(); + + float axis[kAxes]; + for (int a = 0; a < kAxes; ++a) { + float tremor = 0.0f; + for (size_t o = 0; o < tremor_phase_.size(); ++o) { + const uint32_t seed = kTremorSeed + + static_cast(a) * kAxisSeedStep + + static_cast(o) * kOctaveSeedStep; + tremor += + kOctaveWeight[o] * + math::GradientNoise1D(static_cast(tremor_phase_[o]), seed); + } + const float drift = + tremor * kTremorNorm * kTremorAmplitude[a] * tremor_intensity_ + + micro_.Value(a) * kMicroRange[a] * micro_intensity_; + const float posture = posture_.Value(a) * kPostureRange[a]; + axis[a] = drift + posture * posture_intensity_; + } + + // Steering is positive to the left; the head tilts the same way (a + // negative roll). + const float steering = + std::clamp(controls.effectiveInput.steering, -1.0f, 1.0f); + const float tilt = std::copysign(std::sqrt(std::fabs(steering)), steering); + axis[kRoll] += + steering_roll_.Update(-kSteeringTiltDeg * tilt * steering_tilt_, dt); + + // The axes are in the head's frame. A right-hand drive truck's camera + // mirrors X (see CabinWalkEffect::CameraX) and roll (seen in game), but + // not yaw. + const float camera_x = right_hand_drive_ ? -1.0f : 1.0f; + const Vec3 neck = + NeckPivot(axis[kYaw] * math::kDegToRad, axis[kPitch] * math::kDegToRad, + axis[kRoll] * math::kDegToRad); + + HeadOffset offset; + offset.pos_x = camera_x * (axis[kX] + neck.x); + offset.pos_y = axis[kY] + neck.y; + offset.pos_z = axis[kZ] + neck.z; + offset.yaw = axis[kYaw]; + offset.pitch = axis[kPitch]; + offset.roll = camera_x * axis[kRoll]; + return offset; +} + +} // namespace motioncab diff --git a/src/effects/cabin/NaturalHeadMovementEffect.hpp b/src/effects/cabin/NaturalHeadMovementEffect.hpp new file mode 100644 index 0000000..026bef5 --- /dev/null +++ b/src/effects/cabin/NaturalHeadMovementEffect.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include "core/Settings.hpp" +#include "effects/ConfigurableEffect.hpp" +#include "math/SpringDamper.hpp" + +#include +#include + +namespace motioncab { + +// Moves the head on its own, as with a head tracker. Summed layers: a +// noise tremor, micro-adjustments, resting postures (body shapes, wider and +// slower once stopped a while than driving) and a tilt into the turn with +// the steering wheel. Moves ease with math::SmootherStep, longer the +// farther they go (bar the switch between idle and driving, a set time), +// and every rotation pivots on the neck, which moves the eyes too. Pauses +// Idle Breathing and Speed Shake (conflicts::kPauses). +// SCS local space: X right, Y up, Z back; yaw positive left, pitch +// negative down, roll positive right (X and roll mirrored on a right-hand +// drive truck). +class NaturalHeadMovementEffect final : public ConfigurableEffect { +public: + NaturalHeadMovementEffect(SPF_Config_API *config_api, + SPF_Config_Handle *config_handle); + + void Reset() override; + bool NeedsDriverSeat() const override { return true; } + HeadOffset Update(float dt, const SPF_TruckData &truck, + const SPF_Controls &controls) override; + void OnTruckConstantsChanged(const SPF_TruckConstants &constants) override; + +private: + void LoadSettings() override; + + // The axes the head moves along, in HeadOffset's order. + enum Axis { kX, kY, kZ, kYaw, kPitch, kRoll, kAxes }; + + using Pose = std::array; + + // A layer's pose per axis, in -1..1 of its range, moving from `from` to + // `to` over `duration` seconds. + struct Layer { + Pose from{}; + Pose to{}; + float elapsed = 0.0f; + float duration = 1.0f; + float timer = 0.0f; // seconds until the next move + + float Value(int axis) const; + // Starts a move from where the layer is now to `target`, over + // `min_seconds` for no distance up to `max_seconds` for a whole range. + void MoveTo(const Pose &target, float min_seconds, float max_seconds); + void Advance(float dt); + void Reset(); + }; + + float Jitter(float mean); // `mean` times a random 0.5..1.5 + void DrawMicro(); + // Draws a posture of the current mode and moves to it, and sets the time + // until the next. + void DrawPosture(); + + float tremor_intensity_ = settings::Default( + "settings.cabin.natural_head_movement.tremor_intensity"); + float micro_intensity_ = + settings::Default("settings.cabin.natural_head_movement.micro_intensity"); + float posture_intensity_ = settings::Default( + "settings.cabin.natural_head_movement.posture_intensity"); + // seconds, mean time between two postures + float posture_interval_ = settings::Default( + "settings.cabin.natural_head_movement.posture_interval"); + // scales the tilt into the turn + float steering_tilt_ = + settings::Default("settings.cabin.natural_head_movement.steering_tilt"); + + // The camera's X is mirrored on a right-hand drive truck. + bool right_hand_drive_ = false; + + // The tremor's two noise octaves, in noise cells, wrapped with + // math::WrapNoisePhase since they run for the whole session. + std::array tremor_phase_{}; + Layer micro_; + Layer posture_; + float stopped_for_ = 0.0f; // seconds under the idle speed + bool idle_ = false; // the posture mode, see the class comment + // Degrees of roll, following the steering wheel: a continuous input, so a + // spring rather than a Layer's moves. + math::SpringDamper1D steering_roll_; + + std::minstd_rand rng_; +}; + +} // namespace motioncab diff --git a/src/ui/EffectTabs.cpp b/src/ui/EffectTabs.cpp index 2f4f51c..99c2b41 100644 --- a/src/ui/EffectTabs.cpp +++ b/src/ui/EffectTabs.cpp @@ -1,6 +1,7 @@ #include "EffectTabs.hpp" #include "SPF_Icons.h" +#include "core/Conflicts.hpp" #include "core/Keybinds.hpp" #include "core/Loc.hpp" #include "core/Settings.hpp" @@ -224,6 +225,7 @@ constexpr EffectUi kEffects[] = { {ICON_FA_LUNGS, "settings.cabin.idle_breathing", false, {}}, {ICON_FA_WAVE_SQUARE, "settings.cabin.engine_vibration", false, {}}, {ICON_FA_POWER_OFF, "settings.cabin.engine_start_stop", false, {}}, + {ICON_FA_PERSON_RAYS, "settings.cabin.natural_head_movement", false, {}}, {ICON_FA_EYE, "settings.manual.mirror_check", true, {}}, {ICON_FA_ARROWS_LEFT_RIGHT, "settings.manual.manual_look", true, kManualLookKeybinds}, @@ -257,6 +259,51 @@ consteval bool EffectsHaveEnabledToggle() { static_assert(EffectsHaveEnabledToggle(), "every effect in kEffects needs an \"enabled\" setting"); +// Whether the effect under `prefix` is enabled. +bool IsEffectEnabled(SPF_Config_API *cfg, SPF_Config_Handle *h, + std::string_view prefix) { + const settings::Setting &toggle = + *settings::Find(std::string(prefix) + ".enabled"); + return cfg->Cfg_GetBool(h, toggle.key, toggle.default_bool()); +} + +// "When enabled, this pauses the following effect(s):" and a bullet list, +// under an effect that pauses others (conflicts::kPauses). +void DrawPausesHint(SPF_UI_API *ui, std::string_view prefix) { + bool any = false; + for (const conflicts::Pause &p : conflicts::kPauses) { + if (prefix != p.effect) + continue; + if (!any) { + DrawWrappedHint(ui, loc::Tr("settings.pauses")); + ui->UI_PushStyleColor(SPF_COLOR_TEXT, 0.6f, 0.6f, 0.6f, 1.0f); + any = true; + } + ui->UI_BulletText(SettingTitle(p.paused)); + } + if (any) + ui->UI_PopStyleColor(1); +} + +// "Paused by X, Y." under an effect an enabled one pauses; returns whether +// it's paused. +bool DrawPausedByHint(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, + std::string_view prefix) { + std::string names; + for (const conflicts::Pause &p : conflicts::kPauses) { + if (prefix != p.paused || !IsEffectEnabled(cfg, h, p.effect)) + continue; + if (!names.empty()) + names += ", "; + names += SettingTitle(p.effect); + } + if (names.empty()) + return false; + DrawWrappedHint(ui, + loc::Tr("settings.paused_by", {{"effects", names}}).c_str()); + return true; +} + void DrawEffect(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, const EffectUi &effect) { if (!EffectHeader(ui, effect.icon, effect.prefix)) @@ -266,7 +313,9 @@ void DrawEffect(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, DrawEnabled(ui, cfg, h, *settings::Find(prefix + ".enabled")); if (effect.has_hint) DrawWrappedHint(ui, loc::Tr(prefix + ".hint")); - ui->UI_BeginDisabled(!enabled); + DrawPausesHint(ui, effect.prefix); + const bool paused = DrawPausedByHint(ui, cfg, h, effect.prefix); + ui->UI_BeginDisabled(!enabled || paused); // "_table", unique per effect. const std::string table_id = prefix.substr(prefix.rfind('.') + 1) + "_table"; if (BeginSettingsTable(ui, table_id.c_str())) {