From e635eb01f87b476d8f0d27d195f7cfff5701e126 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:44:45 +0200 Subject: [PATCH 1/5] build(spf): re-vendor SPF 1.2.5 API headers --- SPF_API/SPF_Environment_API.h | 81 +++++ SPF_API/SPF_KeyBinds_API.h | 8 + SPF_API/SPF_Sound_API.h | 618 ++++++++++++++++++++++++++++++++-- 3 files changed, 678 insertions(+), 29 deletions(-) diff --git a/SPF_API/SPF_Environment_API.h b/SPF_API/SPF_Environment_API.h index b7fd638..919935e 100644 --- a/SPF_API/SPF_Environment_API.h +++ b/SPF_API/SPF_Environment_API.h @@ -67,6 +67,16 @@ extern "C" { */ typedef struct SPF_Environment_Handle SPF_Environment_Handle; +/** + * @brief Describes one entry from the game's VFS mount tables (Section 8). + */ +typedef struct SPF_VfsMountInfo { + char vpath[256]; /**< Virtual path (e.g. "/spf/MyPlugin", "/home"). */ + char physical_path[512]; /**< Physical disk path backing the mount. */ + int pool_index; /**< 0 = core, 1 = user, 2 = mod, 3 = scs, 4 = root. */ + int order; /**< Mount order inside the pool (higher = resolved earlier). */ +} SPF_VfsMountInfo; + /** * @struct SPF_Environment_API * @brief Table of function pointers to access environment data. @@ -364,6 +374,77 @@ typedef struct SPF_Environment_API { */ int (*Env_GetActiveProfileType)(SPF_Environment_Handle* h, char* out_buffer, int buffer_size); + // ============================================================================================= + // Section 7: Plugin VFS Mounting + // ============================================================================================= + + /** + * @brief Mounts a physical directory into the game VFS under "/spf/". + * @details The game resolves files under the virtual path to the plugin's physical + * directory. The mount is idempotent: calling again with the same directory + * returns the same virtual path. All mounts are automatically removed when + * the plugin is disabled or unloaded, even if the plugin forgets to unmount. + * @param h The context handle obtained from Env_GetContext. + * @param physical_path Physical directory to mount (e.g. from Env_GetPluginDir). + * @param pool_index VFS pool: -1 = default (user), 0 = core, 1 = user, 2 = mod, 3 = scs. + * Pool precedence is fixed (highest first): core > user > mod > scs; the + * first pool holding the file wins. Mount recency does not determine the + * winner: a newer mount does not override an older one — even if a mod + * folder is mounted later, base content registered earlier may still + * shadow it. + * @param order Mount order inside the pool: higher value = resolved earlier. + * Only matters when two mounts in the same pool overlap in virtual path + * prefixes (e.g. "/spf/A" vs "/spf/A/sub"); unrelated prefixes never + * compete. The game itself uses 100000 (base), 159-190 (DLC), 1001+ + * (mods), 650 (home), 0 (steam). Pick a value below the game content of + * the target pool (e.g. 650 for user/mod pools) if the plugin content + * must lose to it. Any int is accepted; no game-side limits. + * @param out_vpath Buffer to receive the virtual path (e.g. "/spf/MyPlugin"). + * @param buffer_size Size of the output buffer. + * @return true if the directory is mounted (or already was). + * @note Must be called from the game thread (OnActivated / OnGameWorldReady). + */ + bool (*Env_VfsMount)(SPF_Environment_Handle* h, const char* physical_path, int pool_index, int order, char* out_vpath, int buffer_size); + + /** + * @brief Unmounts all VFS mounts created by this plugin. + * @param h The context handle obtained from Env_GetContext. + * @return true. + */ + bool (*Env_VfsUnmount)(SPF_Environment_Handle* h); + + // ============================================================================================= + // Section 8: VFS Mount Enumeration + // ============================================================================================= + + /** + * @brief Gets the total number of mounts across all VFS pools. + * @param h The context handle obtained from Env_GetContext. + * @return Total mount count in the game's live tables (includes game and mod + * mounts, not only this plugin's), or 0 when the finder is not ready. + * @note Call from the game thread. The count comes from a snapshot cache that + * is invalidated only when the game's mount tables change. + */ + int (*Env_VfsGetMountCount)(SPF_Environment_Handle* h); + + /** + * @brief Reads one mount entry by flat index (pools in index order). + * @details On success fills all SPF_VfsMountInfo fields from the game's live + * mount tables: vpath (virtual path), physical_path (disk path), + * pool_index and order. Entries cover every mount in the game, + * including game and mod mounts, not only this plugin's. + * @param h The context handle obtained from Env_GetContext. + * @param index Zero-based index in [0, Env_VfsGetMountCount). + * @param out_info Struct receiving the mount entry. Only valid when this + * function returns true; on false its contents are untouched. + * @return true if the index was valid and out_info was filled. + * @note Strings may be empty when the backing game memory could not be read; + * pool_index and order are always set on success. Call from the game + * thread. Iterate: count = Env_VfsGetMountCount(h); for (i = 0; i < + * count; ++i) Env_VfsGetMountAt(h, i, &info). + */ + bool (*Env_VfsGetMountAt)(SPF_Environment_Handle* h, int index, SPF_VfsMountInfo* out_info); + } SPF_Environment_API; #ifdef __cplusplus diff --git a/SPF_API/SPF_KeyBinds_API.h b/SPF_API/SPF_KeyBinds_API.h index e4555b0..2a9f259 100644 --- a/SPF_API/SPF_KeyBinds_API.h +++ b/SPF_API/SPF_KeyBinds_API.h @@ -265,6 +265,8 @@ typedef struct SPF_KeyBinds_API { * @details This function is only effective if the action's 'consume' policy is set to 'manual' * in the framework settings. It allows the plugin to decide at runtime when a * shared key (e.g., WASD) should be captured by the plugin or passed to the game. + * The new state applies from the next press of the key, and `Kbind_GetActionValue` + * keeps reporting the input while it is blocked from the game. * * @param h The context handle. * @param actionName The logical name of the action (e.g., "Movement.Forward"). @@ -288,6 +290,12 @@ typedef struct SPF_KeyBinds_API { * It **ignores** logical behaviors such as 'toggle', 'hold', or 'press_type' (short/long). * If you need to react to these logical events, use `Kbind_Register` instead. * + * ### Blocking and UI Capture: + * - An input blocked from the game with `Kbind_SetBlockState` is still reported here. + * - While the framework UI captures the keyboard (a text field being edited, a modal + * popup open, a widget held with the mouse), keyboard inputs read as released, + * just as they don't trigger `Kbind_Register` callbacks. + * * ### Expected Value Ranges: * - **Digital Buttons (Keyboard/Gamepad):** Returns either `0.0` (released) or `1.0` (pressed). * - **Analog Triggers:** Returns `0.0` to `1.0`. diff --git a/SPF_API/SPF_Sound_API.h b/SPF_API/SPF_Sound_API.h index 1e30a4d..081a5bc 100644 --- a/SPF_API/SPF_Sound_API.h +++ b/SPF_API/SPF_Sound_API.h @@ -2,12 +2,55 @@ /** * @file SPF_Sound_API.h - * @brief C-style API for the FMOD sound system, exposed to plugins. + * @brief C-style API for the sound system, exposed to plugins. * - * @details This header provides complete access to the game's FMOD Studio sound system. - * Plugins can enumerate sound banks, events, buses, and VCAs; control playback; - * adjust bus and global parameters; manage listeners; and override FMOD parameters - * at the hook level. + * @details This header provides access to the game's sound system through TWO + * independent layers. Choose the layer by answering one question: + * WHO should drive the sound — the plugin, or the game? + * + * ================================================================================================ + * ACCESS LAYERS + * ================================================================================================ + * + * [FMOD layer] — direct FMOD Studio access (implemented via FmodApi / FmodStudioHook). + * The plugin talks straight to FMOD: loads banks, enumerates events, creates + * instances, starts/stops playback, sets parameters, mixes buses and VCAs, + * controls listeners. THE GAME KNOWS NOTHING about these sounds — no game + * logic triggers, updates or stops them. Use this layer when: + * - the sound is entirely plugin-owned (custom music, plugin UI sounds, + * notifications, footsteps of a custom character); + * - the event does not exist in the game at all (e.g. a footsteps event + * the game never had); + * - the plugin must control playback timing itself. + * Banks loaded via SND_LoadBankFile / SND_LoadBankMemory go directly into FMOD: + * the game cannot see or manage such a bank — it never appears in the game's + * bank list and the game will not unload it. The plugin owns its lifetime. + * + * [Game layer] — SoundRef rebinding (".soundref" -> "bank#event"). + * The game maps its own sounds through ".soundref" files and activates them + * itself (UI clicks, horn, engine, world sounds). Use this layer when the + * plugin wants to REPLACE a sound the game plays, while the game keeps + * triggering, timing and stopping it exactly as before: + * 1. SND_RegisterSoundRefOverride("/sound/ui/ui_click.soundref", + * "my_bank#ui/click_v2"); + * 2. the game — including already-created events — switches to the new + * "bank#event"; + * 3. SND_UnregisterSoundRefOverride() / SND_ClearSoundRefOverrides() + * restores the originals. + * No plugin-side playback control is involved — the game remains the caller. + * + * Decision rule: + * - the game should keep calling the sound -> SoundRef (game layer) + * - the plugin drives playback / event not in the game -> FMOD (direct layer) + * + * ================================================================================================ + * THREAD SAFETY + * ================================================================================================ + * + * FMOD-layer functions must be called from the game thread (FMOD's command queue + * is processed there only; calling from other threads corrupts FMOD state). + * SoundRef (game layer) functions may be called from any thread; rebinds are + * applied on the game tick. * * ================================================================================================ * LIFECYCLE @@ -29,11 +72,18 @@ * 2. Control: SND_SetBusVolume() / SND_SetVCAVolume() / SND_SetBusMute() * * Bank management: - * 1. Load: SND_LoadBankFile() + * 1. Load: SND_LoadBankFile() / SND_LoadBankMemory() * 2. Query: SND_GetBankLoadingState() / SND_GetBankEventCount() * 3. Discover events: SND_GetBankEventGuid() + SND_FindEventIndexByGuid() * 4. Unload: SND_UnloadBank() * + * SoundRef rebinding (game layer): + * 1. Enumerate: SND_GetSoundRefCount() + SND_GetSoundRefPath() / SND_GetSoundRefSource() + * 2. Find: SND_FindSoundRefIndex() / SND_FindSoundRefBySource() + * 3. Rebind: SND_RegisterSoundRefOverride() + * 4. Inspect: SND_GetSoundRefOverride() / SND_IsSoundRefActive() + * 5. Restore: SND_UnregisterSoundRefOverride() / SND_ClearSoundRefOverrides() + * * ================================================================================================ * ABI STABILITY * ================================================================================================ @@ -119,7 +169,7 @@ typedef bool (*SPF_SND_AreAllOffsetsFound_t)(); typedef bool (*SPF_SND_RefreshOffsets_t)(); // ================================================================================================= -// BUS ENUMERATION & CONTROL +// BUS ENUMERATION & CONTROL [FMOD LAYER] // ================================================================================================= /** @@ -207,7 +257,7 @@ typedef bool (*SPF_SND_GetBusPause_t)(int index); typedef bool (*SPF_SND_SetBusPause_t)(int index, bool paused); // ================================================================================================= -// VCA (VOLUME CONTROL ASSOCIATION) +// VCA (VOLUME CONTROL ASSOCIATION) [FMOD LAYER] // ================================================================================================= /** @@ -250,7 +300,7 @@ typedef float (*SPF_SND_GetVCAVolume_t)(int index); typedef bool (*SPF_SND_SetVCAVolume_t)(int index, float volume); // ================================================================================================= -// GLOBAL PARAMETERS +// GLOBAL PARAMETERS [FMOD LAYER] // ================================================================================================= /** @@ -300,7 +350,7 @@ typedef float (*SPF_SND_GetGlobalParamValue_t)(const char* param_name); typedef bool (*SPF_SND_SetGlobalParamValue_t)(const char* param_name, float value); // ================================================================================================= -// EVENT ENUMERATION +// EVENT ENUMERATION [FMOD LAYER] // ================================================================================================= /** @@ -467,7 +517,7 @@ typedef int (*SPF_SND_GetEventLiveInstanceCount_t)(int event_index); typedef void* (*SPF_SND_GetEventLiveInstance_t)(int event_index, int instance_index); // ================================================================================================= -// EVENT PLAYBACK +// EVENT PLAYBACK [FMOD LAYER] // ================================================================================================= /** @@ -532,7 +582,7 @@ typedef int (*SPF_SND_GetEventPlaybackState_t)(void* instance); typedef void (*SPF_SND_ReleaseEvent_t)(void* instance); // ================================================================================================= -// EVENT INSTANCE PROPERTIES +// EVENT INSTANCE PROPERTIES [FMOD LAYER] // ================================================================================================= /** @@ -692,7 +742,7 @@ typedef int (*SPF_SND_GetEventLoopCount_t)(void* instance); typedef bool (*SPF_SND_SetEventCallback_t)(void* instance, SPF_SND_EventCallbackFn callback, uint32_t callback_mask); // ================================================================================================= -// LISTENER CONTROL +// LISTENER CONTROL [FMOD LAYER] // ================================================================================================= /** @@ -755,7 +805,7 @@ typedef bool (*SPF_SND_GetListenerAttributes_t)(int index, float* out_pos_x, flo typedef bool (*SPF_SND_SetListenerAttributes_t)(int index, float pos_x, float pos_y, float pos_z, float vel_x, float vel_y, float vel_z, float fwd_x, float fwd_y, float fwd_z, float up_x, float up_y, float up_z); // ================================================================================================= -// BANK MANAGEMENT +// BANK MANAGEMENT [FMOD LAYER] // ================================================================================================= /** @@ -770,12 +820,30 @@ typedef bool (*SPF_SND_SetListenerAttributes_t)(int index, float pos_x, float po * The dictionary enables path-based event lookup (SND_FindEventIndexByPath) * for plugin-bank events that FMOD cannot resolve internally. * + * FMOD layer: the game cannot see or manage this bank — it does not + * appear in the game bank list and the game will not unload it. + * * @param bank_path Filesystem path to the .bank file. * @param guids_path Path to the GUIDs dictionary file, or null for auto-discovery. * @return Opaque bank pointer, or NULL on failure. */ typedef void* (*SPF_SND_LoadBankFile_t)(const char* bank_path, const char* guids_path); +/** + * @brief Loads a bank from a memory buffer and optionally resolves event GUIDs from a dictionary. + * + * @details FMOD layer: the bank is loaded directly into FMOD — the game does not + * see it, does not list it and will not unload it. The plugin owns the + * buffer lifetime and the bank lifetime (unload with SND_UnloadBank()). + * Same GUID dictionary semantics as SND_LoadBankFile(). + * + * @param data Pointer to the raw .bank file bytes. + * @param size Size of the buffer in bytes. + * @param guids_path Path to the GUIDs dictionary file, or null for auto-discovery. + * @return Opaque bank pointer, or NULL on failure. + */ +typedef void* (*SPF_SND_LoadBankMemory_t)(const void* data, uint32_t size, const char* guids_path); + /** * @brief Returns the loading state of a bank. * @@ -849,7 +917,7 @@ typedef int (*SPF_SND_GetBankPath_t)(int index, char* out_buffer, int buffer_siz typedef bool (*SPF_SND_UnloadBank_t)(void* bank); // ================================================================================================= -// EVENT DESCRIPTION INTROSPECTION +// EVENT DESCRIPTION INTROSPECTION [FMOD LAYER] // ================================================================================================= /** @@ -918,7 +986,7 @@ typedef uint32_t (*SPF_SND_GetEventSoundSize_t)(int event_index); typedef int (*SPF_SND_GetEventSampleLoadingState_t)(int event_index); // ================================================================================================= -// FMOD HOOK OVERRIDES +// FMOD HOOK OVERRIDES [FMOD LAYER — INTERCEPTS THE GAME'S OWN FMOD READS] // ================================================================================================= /** @@ -980,13 +1048,447 @@ typedef void (*SPF_SND_Reset3DToOriginal_t)(const char* event_path); typedef bool (*SPF_SND_HasOverrides_t)(); /** - * @brief Removes ALL active FMOD hook overrides (parameters and 3D). + * @brief Removes ALL active FMOD hook overrides (parameters, 3D, volume, pitch). * - * @details After this call, all events revert to the game's original parameter - * and 3D attribute values. + * @details After this call, all events revert to the game's original parameter, + * 3D attribute, volume and pitch values. Suppression prefixes + * (SND_SuppressEventPlayback) are NOT affected — remove them explicitly. */ typedef void (*SPF_SND_RemoveAllOverrides_t)(); +/** + * @brief Forces the FMOD volume of every instance of an event path (FMOD hook layer). + * + * @details Intercepts EventInstance::setVolume for instances whose resolved + * event path equals @p event_path and replaces the value the game + * requested. The game's own volume logic keeps running — its writes + * are just replaced at the FMOD boundary. Remove the override to let + * the game's values through again. + * + * @param event_path Exact FMOD event path (e.g. "event:/horn/truck"). + * @param volume Forced volume (0.0 = silent, 1.0 = full). + */ +typedef void (*SPF_SND_OverrideEventVolume_t)(const char* event_path, float volume); + +/** + * @brief Removes a volume override installed by SND_OverrideEventVolume. + * + * @param event_path Exact FMOD event path the override was installed for. + */ +typedef void (*SPF_SND_RemoveEventVolumeOverride_t)(const char* event_path); + +/** + * @brief Forces the FMOD pitch of every instance of an event path (FMOD hook layer). + * + * @details Same interception model as SND_OverrideEventVolume, on + * EventInstance::setPitch. + * + * @param event_path Exact FMOD event path. + * @param pitch Forced pitch multiplier (1.0 = unchanged). + */ +typedef void (*SPF_SND_OverrideEventPitch_t)(const char* event_path, float pitch); + +/** + * @brief Removes a pitch override installed by SND_OverrideEventPitch. + * + * @param event_path Exact FMOD event path the override was installed for. + */ +typedef void (*SPF_SND_RemoveEventPitchOverride_t)(const char* event_path); + +// ================================================================================================= +// FMOD INTERCEPTION [SUPPRESSION / ACTIVITY OBSERVATION] (FMOD hook layer) +// ================================================================================================= + +// Activity codes delivered to SPF_SND_ActivityCallback_t. +#define SPF_ACTIVITY_EVENT_CREATED 1 // EventDescription::createInstance returned a new instance +#define SPF_ACTIVITY_EVENT_START_SUPPRESSED 2 // EventInstance::start was blocked by SND_SuppressEventPlayback +#define SPF_ACTIVITY_EVENT_STARTED 3 // EventInstance::start executed successfully +#define SPF_ACTIVITY_EVENT_STOPPED 4 // EventInstance::stop called +#define SPF_ACTIVITY_EVENT_PAUSED 5 // EventInstance::setPaused(true) called +#define SPF_ACTIVITY_EVENT_UNPAUSED 6 // EventInstance::setPaused(false) called +#define SPF_ACTIVITY_EVENT_RELEASED 7 // EventInstance::release called (path resolved before release) +#define SPF_ACTIVITY_EVENT_PARAM_SET 8 // setParameterByName/ByID called (param_name/param_value valid) +#define SPF_ACTIVITY_BANK_LOADED 9 // System::loadBank* succeeded (path = bank path) +#define SPF_ACTIVITY_BANK_UNLOADING 10 // Bank::unload called (path = bank path) + +/** + * @brief Callback observing FMOD activity — the interception layer's event feed. + * + * @details Invoked synchronously from inside the intercepting detour, on the + * thread that made the FMOD call (normally the game thread) — there is + * no queuing. @p path and @p param_name are valid only for the + * duration of the call; copy what you need. Nested delivery is + * suppressed (activity fired from inside your own callback is + * dropped), but calling SND_* FMOD functions from the callback is + * still unsafe — buffer the data and act on it later (e.g. in your + * OnUpdate). + * + * @param user_data Pointer passed to SND_SetActivityCallback. + * @param activity SPF_ACTIVITY_* code. + * @param path Event path, or bank path for SPF_ACTIVITY_BANK_*; "" if unresolvable. + * @param instance FMOD instance involved (NULL for bank activities). + * @param param_name Parameter name for SPF_ACTIVITY_EVENT_PARAM_SET, else NULL. + * @param param_value Effective value passed to FMOD for SPF_ACTIVITY_EVENT_PARAM_SET, else 0. + */ +typedef void (*SPF_SND_ActivityCallback_t)(void* user_data, int activity, const char* path, + void* instance, const char* param_name, float param_value); + +/** + * @brief Registers (or with NULL, unregisters) the FMOD activity callback. + * + * @details One callback per process; a new registration replaces the previous + * one. Survives until replaced or the hook is uninstalled. + * + * @param callback Callback function, or NULL to unregister. + * @param user_data Opaque pointer delivered back to @p callback. + */ +typedef void (*SPF_SND_SetActivityCallback_t)(SPF_SND_ActivityCallback_t callback, void* user_data); + +/** + * @brief Blocks playback start for every event path under a prefix (FMOD hook layer). + * + * @details Installs a prefix rule on the EventInstance::start detour: any start + * call for an event path beginning with @p path_prefix returns FMOD_OK + * without playing — the game's horn never reaches the speakers, from + * the first sample. The blocked start still counts: watch + * SND_GetSuppressedStartCount for press edges. The game may keep + * calling setParameter on the silent instance (e.g. "play") — those + * writes pass through untouched. Matching is case-sensitive prefix + * match; the rule applies to instances created before or after the + * call. Suppression rules survive until SND_UnsuppressEventPlayback. + * + * @param path_prefix Event path prefix (e.g. "event:/horn/"). + * @return true when the start hook is active and the rule installed; false on + * empty prefix or when the hook is unavailable. + */ +typedef bool (*SPF_SND_SuppressEventPlayback_t)(const char* path_prefix); + +/** + * @brief Removes a suppression rule installed by SND_SuppressEventPlayback. + * + * @details Instances already blocked stay silent until their next start call, + * which now plays normally. + * + * @param path_prefix The prefix rule to remove. + * @return true when a rule was found and removed; false otherwise. + */ +typedef bool (*SPF_SND_UnsuppressEventPlayback_t)(const char* path_prefix); + +/** + * @brief Returns how many start attempts were suppressed under a prefix. + * + * @details The counter resets when the prefix rule is removed or the hook + * reinstalled. Compare against a stored baseline to detect presses — + * the value only grows while the rule exists. + * + * @param path_prefix The suppression prefix. + * @return Suppressed-start attempt count, or 0 when no rule exists. + */ +typedef uint64_t (*SPF_SND_GetSuppressedStartCount_t)(const char* path_prefix); + +/** + * @brief Returns the FMOD instance of the most recent suppressed start under a prefix. + * + * @details Use it to read per-instance state the game keeps on the silent + * instance (e.g. the "play" parameter) to detect release. The pointer + * becomes NULL once the game releases that instance; never cache it + * across frames — call this every frame instead. The instance is + * stopped (its start was blocked) but alive and queryable. + * + * @param path_prefix The suppression prefix. + * @return FMOD instance pointer, or NULL when none / already released. + */ +typedef void* (*SPF_SND_GetLastSuppressedInstance_t)(const char* path_prefix); + +// ================================================================================================= +// GAME sound_event CONTROL [GAME LAYER] +// ================================================================================================= + +/** + * @brief Returns the number of live game sound_event_t objects. + * + * @details Walks per-bank event lists and builds a fresh snapshot on every + * call. Handles obtained via SND_GetGameEventAt stay valid while the + * event exists — prefer fetching handles once over repeated counts. + * + * @return Event count, or 0 if the sound system is not ready. + */ +typedef int (*SPF_SND_GetGameEventCount_t)(); + +/** + * @brief Returns the sound_event_t handle at @p index within a fresh snapshot. + * + * @param index Zero-based index (0 to SND_GetGameEventCount()-1 of the same snapshot). + * @return Opaque handle, or NULL when out of range. + */ +typedef void* (*SPF_SND_GetGameEventAt_t)(int index); + +/** + * @brief Finds the first game sound_event whose path equals @p path. + * + * @param path Exact event path string (game's own path field). + * @return Opaque handle, or NULL if no event matches. + */ +typedef void* (*SPF_SND_FindGameEventByPath_t)(const char* path); + +/** + * @brief Finds the first game sound_event whose source equals @p source. + * + * @param source Exact "bank#event" source string. + * @return Opaque handle, or NULL if no event matches. + */ +typedef void* (*SPF_SND_FindGameEventBySource_t)(const char* source); + +/** + * @brief Finds the game sound_event that owns @p instance. + * + * Reverse FMOD→game mapping: matches the instance field of every live + * sound_event against @p instance. + * + * @param instance FMOD EventInstance* handle. + * @return Opaque game event handle, or NULL if no game event owns it + * (plugin-created / foreign instances resolve to NULL). + */ +typedef void* (*SPF_SND_FindGameEventByInstance_t)(void* instance); + +/** + * @brief Reads the playback state dword of a game sound_event. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @return SPF_SOUND_EVENT_STATE_* value; 0 if unavailable. + */ +typedef uint32_t (*SPF_SND_GetGameEventPlaybackState_t)(void* event); + +/** + * @brief Reports whether the game event is currently bound (state == 2). + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @return true when bound, false otherwise. + */ +typedef bool (*SPF_SND_IsGameEventBound_t)(void* event); + +/** + * @brief Recreates the event's EventInstance from its current source. + * + * @details Stops and releases the previous instance first (never leaks), then + * runs the game's own activate path. Does NOT auto-start playback — + * call SND_GameEvent_Start for that. Game thread only. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @return true when activate succeeded. + */ +typedef bool (*SPF_SND_GameEvent_Activate_t)(void* event); + +/** + * @brief Starts (or resumes) a game sound_event through its vtable PlaybackControl. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_Start_t)(void* event); + +/** + * @brief Stops a game sound_event through its vtable Stop; instance is kept + * until the next SND_GameEvent_Activate. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_Stop_t)(void* event); + +/** + * @brief Pauses or resumes a game sound_event through its vtable SetPaused. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param paused true to pause, false to resume. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_SetPaused_t)(void* event, bool paused); + +/** + * @brief Sets event volume through its vtable SetVolume (also cached by the game at +0x80). + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param volume New volume (game convention 0..1). + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_SetVolume_t)(void* event, float volume); + +/** + * @brief Sets event pitch through its vtable SetPitch (cached by the game at +0x84). + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param pitch New pitch multiplier (1.0 = normal). + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_SetPitch_t)(void* event, float pitch); + +/** + * @brief Sets a raw FMOD event property through its vtable SetProperty (cached at +0x88). + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param property_id FMOD event property id (0 = frequency modulation depth, ...). + * @param value New property value. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_SetProperty_t)(void* event, int property_id, float value); + +/** + * @brief Sets 3D position through its vtable Set3DAttributes. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param pos_x,pos_y,pos_z World position; velocity is zeroed by this overload. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_Set3DAttributes_t)(void* event, float pos_x, float pos_y, float pos_z); + +/** + * @brief Sets a parameter on a game sound_event by its 16-byte FMOD ID. + * + * @param event Handle from SND_FindGameEventByPath/BySource/At. + * @param id 16-byte parameter GUID (same layout as SPD_GET_PARAMETER_ID_GUID). + * @param value New parameter value. + * @return true on success. + */ +typedef bool (*SPF_SND_GameEvent_SetParameterByID_t)(void* event, const uint8_t id[16], float value); + +// SoundEvent playback state (sound_event_t+0x30) — SPF_SND_GetGameEventPlaybackState values +#define SPF_SOUND_EVENT_STATE_STOPPED 0u // stopped manually / never started +#define SPF_SOUND_EVENT_STATE_PLAYING 1u +#define SPF_SOUND_EVENT_STATE_PAUSED 3u +#define SPF_SOUND_EVENT_STATE_ENDED 4u // finished on its own + +// ================================================================================================= +// SOUNDREF CATALOG & REBINDING [GAME LAYER] +// ================================================================================================= + +/** + * @brief Returns the number of known .soundref paths. + * + * @details The catalog merges all sources: static game tables (UI, voice-nav), + * ".soundref" path references found in the game binary, files enumerated + * from the game VFS, and currently live sound events. Entries with a live + * event additionally expose their current "bank#event" source. + * + * @return SoundRef count, or 0 if the sound system is not ready. + */ +typedef int (*SPF_SND_GetSoundRefCount_t)(); + +/** + * @brief Copies the .soundref path of a catalog entry into the provided buffer. + * + * @param index Zero-based catalog index (0 to SND_GetSoundRefCount()-1). + * @param out_buffer Buffer to receive the path string. + * @param buffer_size Size of the output buffer in bytes. + * @return The full path length excluding the null terminator, or -1 if the index is invalid. + */ +typedef int (*SPF_SND_GetSoundRefPath_t)(int index, char* out_buffer, int buffer_size); + +/** + * @brief Copies the current "bank#event" source of a catalog entry. + * + * @details Non-empty only when the game has a live sound_event for this path + * (SND_IsSoundRefActive). Reflects any registered override once applied. + * + * @param index Zero-based catalog index. + * @param out_buffer Buffer to receive the source string. + * @param buffer_size Size of the output buffer in bytes. + * @return The full source length excluding the null terminator, or -1 if the index is invalid. + */ +typedef int (*SPF_SND_GetSoundRefSource_t)(int index, char* out_buffer, int buffer_size); + +/** + * @brief Searches the catalog for a ".soundref" path. + * + * @param soundref_path Full .soundref path (e.g. "/sound/ui/ui_click.soundref"). + * @return Zero-based catalog index, or -1 if not found. + */ +typedef int (*SPF_SND_FindSoundRefIndex_t)(const char* soundref_path); + +/** + * @brief Searches for the first catalog entry whose live source equals the given "bank#event". + * + * @details Useful to discover which soundref currently points at a given event, + * e.g. before rebinding everything that plays "music/main_menu" from a + * specific bank. + * + * @param source "bank#event" string (e.g. "sound/ui/ui.bank#click"). + * @return Zero-based catalog index, or -1 if not found. + */ +typedef int (*SPF_SND_FindSoundRefBySource_t)(const char* source); + +/** + * @brief Returns whether the game currently has a live sound_event for the path. + * + * @param soundref_path Full .soundref path. + * @return true if a live event exists, false otherwise. + */ +typedef bool (*SPF_SND_IsSoundRefActive_t)(const char* soundref_path); + +/** + * @brief PURE registration of a ".soundref" -> "bank#event" override (map only). + * + * @details No side effects at call time: nothing is applied to live events, no + * bank is loaded, no instance is stopped or started. The override is + * consulted inside the game's SoundRef load pipeline, so it takes + * effect on the soundref's NEXT activation by the game. For immediate + * audible replacement use SND_SoundRef_Replace. + * + * @param soundref_path Full .soundref path to rebind. + * @param source New source in "bank#event" format (e.g. "my_bank#ui/click_v2"). + * @return true if the override was registered, false on invalid arguments. + */ +typedef bool (*SPF_SND_RegisterSoundRefOverride_t)(const char* soundref_path, const char* source); + +/** + * @brief AUTOMATIC full-cycle replacement — the one-stop "swap my sound" call. + * + * @details Single call does everything, in order: + * 1) registers the override (same map as SND_RegisterSoundRefOverride); + * 2) rewrites the source field of every LIVE game event with this path; + * 3) queues a rebind executed on the game thread: stop current instance + * -> release it -> activate from the new source -> start again if the + * event was playing/paused before. During activate the GAME loads the + * target bank itself through its VFS (part of source before '#'), so + * the bank's VFS directory must be mounted first (Env_VfsMount). + * Events not live right now pick the override up automatically on their + * next activation (same as pure SND_RegisterSoundRefOverride). + * + * @param soundref_path Full .soundref path to rebind. + * @param source New source in "bank#event" format; before '#' is the VFS bank path. + * @return true if registered and the apply pass ran, false on invalid arguments. + */ +typedef bool (*SPF_SND_SoundRef_Replace_t)(const char* soundref_path, const char* source); + +/** + * @brief Removes one override, restoring the original "bank#event" source. + * + * @details Live events with this path are restored to their original source and + * rebound on the game thread with the same stop -> activate -> resume + * lifecycle, so a playing replacement stops and the original resumes. + * A bank that was auto-loaded by SND_SoundRef_Replace stays loaded + * (silent); unload it explicitly with SND_UnloadBank if needed. + * + * @param soundref_path Full .soundref path. + * @return true if an override existed and was removed, false otherwise. + */ +typedef bool (*SPF_SND_UnregisterSoundRefOverride_t)(const char* soundref_path); + +/** + * @brief Removes ALL registered SoundRef overrides, restoring every original source. + */ +typedef void (*SPF_SND_ClearSoundRefOverrides_t)(); + +/** + * @brief Copies the currently registered override source for a path. + * + * @param soundref_path Full .soundref path. + * @param out_buffer Buffer to receive the "bank#event" source. + * @param buffer_size Size of the output buffer in bytes. + * @return The source length (0 if no override is registered), or -1 on invalid arguments. + */ +typedef int (*SPF_SND_GetSoundRefOverride_t)(const char* soundref_path, char* out_buffer, int buffer_size); + // ================================================================================================= // API STRUCTURE // ================================================================================================= @@ -1015,7 +1517,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Bus enumeration and control. + * @brief Bus enumeration and control (FMOD layer). * @{ */ SPF_SND_GetBusCount_t SND_GetBusCount; @@ -1029,7 +1531,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief VCA (Volume Control Association) enumeration and control. + * @brief VCA (Volume Control Association) enumeration and control (FMOD layer). * @{ */ SPF_SND_GetVCACount_t SND_GetVCACount; @@ -1039,7 +1541,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Global parameter enumeration and control. + * @brief Global parameter enumeration and control (FMOD layer). * @{ */ SPF_SND_GetGlobalParamCount_t SND_GetGlobalParamCount; @@ -1050,7 +1552,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Event enumeration (read-only metadata). + * @brief Event enumeration — bank catalog metadata (FMOD layer). * @{ */ SPF_SND_GetEventCount_t SND_GetEventCount; @@ -1072,7 +1574,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Event instance playback control. + * @brief Event instance playback control — plugin-driven playback (FMOD layer). * @{ */ SPF_SND_CreateEventInstance_t SND_CreateEventInstance; @@ -1084,7 +1586,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Event instance property control. + * @brief Event instance property control (FMOD layer). * @{ */ SPF_SND_SetEventVolume_t SND_SetEventVolume; @@ -1103,7 +1605,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Listener control. + * @brief Listener control (FMOD layer). * @{ */ SPF_SND_GetNumListeners_t SND_GetNumListeners; @@ -1113,10 +1615,11 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief Bank management. + * @brief Bank management — banks loaded here are invisible to the game (FMOD layer). * @{ */ SPF_SND_LoadBankFile_t SND_LoadBankFile; + SPF_SND_LoadBankMemory_t SND_LoadBankMemory; SPF_SND_GetBankLoadingState_t SND_GetBankLoadingState; SPF_SND_GetBankEventCount_t SND_GetBankEventCount; SPF_SND_GetBankEventGuid_t SND_GetBankEventGuid; @@ -1127,7 +1630,7 @@ typedef struct SPF_Sound_API { /** @} */ /** - * @brief FMOD hook overrides (parameter and 3D attribute injection). + * @brief FMOD hook overrides — intercept the game's own FMOD reads (FMOD hook layer). * @{ */ SPF_SND_OverrideParameter_t SND_OverrideParameter; @@ -1137,10 +1640,25 @@ typedef struct SPF_Sound_API { SPF_SND_Reset3DToOriginal_t SND_Reset3DToOriginal; SPF_SND_HasOverrides_t SND_HasOverrides; SPF_SND_RemoveAllOverrides_t SND_RemoveAllOverrides; + SPF_SND_OverrideEventVolume_t SND_OverrideEventVolume; + SPF_SND_RemoveEventVolumeOverride_t SND_RemoveEventVolumeOverride; + SPF_SND_OverrideEventPitch_t SND_OverrideEventPitch; + SPF_SND_RemoveEventPitchOverride_t SND_RemoveEventPitchOverride; + /** @} */ + + /** + * @brief FMOD interception — suppression and activity observation (FMOD hook layer). + * @{ + */ + SPF_SND_SetActivityCallback_t SND_SetActivityCallback; + SPF_SND_SuppressEventPlayback_t SND_SuppressEventPlayback; + SPF_SND_UnsuppressEventPlayback_t SND_UnsuppressEventPlayback; + SPF_SND_GetSuppressedStartCount_t SND_GetSuppressedStartCount; + SPF_SND_GetLastSuppressedInstance_t SND_GetLastSuppressedInstance; /** @} */ /** - * @brief Event description introspection (parameters, user properties, sample state). + * @brief Event description introspection — parameters, user properties, sample state (FMOD layer). * @{ */ SPF_SND_GetEventParameterCount_t SND_GetEventParameterCount; @@ -1150,6 +1668,48 @@ typedef struct SPF_Sound_API { SPF_SND_GetEventSoundSize_t SND_GetEventSoundSize; SPF_SND_GetEventSampleLoadingState_t SND_GetEventSampleLoadingState; /** @} */ + + /** + * @brief Game sound_event control — direct handles into the game's own events (game layer). + * @details GAME THREAD ONLY: these calls reach FMOD internals and the game's + * vtable dispatch. Handles are raw game pointers — valid while the + * event exists; bank unload destroys its events, re-query after. + * @{ + */ + SPF_SND_GetGameEventCount_t SND_GetGameEventCount; + SPF_SND_GetGameEventAt_t SND_GetGameEventAt; + SPF_SND_FindGameEventByPath_t SND_FindGameEventByPath; + SPF_SND_FindGameEventBySource_t SND_FindGameEventBySource; + SPF_SND_FindGameEventByInstance_t SND_FindGameEventByInstance; + SPF_SND_GetGameEventPlaybackState_t SND_GetGameEventPlaybackState; + SPF_SND_IsGameEventBound_t SND_IsGameEventBound; + SPF_SND_GameEvent_Activate_t SND_GameEvent_Activate; + SPF_SND_GameEvent_Start_t SND_GameEvent_Start; + SPF_SND_GameEvent_Stop_t SND_GameEvent_Stop; + SPF_SND_GameEvent_SetPaused_t SND_GameEvent_SetPaused; + SPF_SND_GameEvent_SetVolume_t SND_GameEvent_SetVolume; + SPF_SND_GameEvent_SetPitch_t SND_GameEvent_SetPitch; + SPF_SND_GameEvent_SetProperty_t SND_GameEvent_SetProperty; + SPF_SND_GameEvent_Set3DAttributes_t SND_GameEvent_Set3DAttributes; + SPF_SND_GameEvent_SetParameterByID_t SND_GameEvent_SetParameterByID; + /** @} */ + + /** + * @brief SoundRef catalog & rebinding — the game keeps driving the sound (game layer). + * @{ + */ + SPF_SND_GetSoundRefCount_t SND_GetSoundRefCount; + SPF_SND_GetSoundRefPath_t SND_GetSoundRefPath; + SPF_SND_GetSoundRefSource_t SND_GetSoundRefSource; + SPF_SND_FindSoundRefIndex_t SND_FindSoundRefIndex; + SPF_SND_FindSoundRefBySource_t SND_FindSoundRefBySource; + SPF_SND_IsSoundRefActive_t SND_IsSoundRefActive; + SPF_SND_RegisterSoundRefOverride_t SND_RegisterSoundRefOverride; + SPF_SND_SoundRef_Replace_t SND_SoundRef_Replace; + SPF_SND_UnregisterSoundRefOverride_t SND_UnregisterSoundRefOverride; + SPF_SND_ClearSoundRefOverrides_t SND_ClearSoundRefOverrides; + SPF_SND_GetSoundRefOverride_t SND_GetSoundRefOverride; + /** @} */ } SPF_Sound_API; #ifdef __cplusplus From a808fae467b1d1be29165ad72606d823fe595b14 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:46:39 +0200 Subject: [PATCH 2/5] refactor(effects): fade out driver-seat effects away from the wheel Add `Effect::NeedsDriverSeat()` and `EffectManager::SetAtWheel()`: driving motion, road motion, mirror checks and manual looks fade out while the player is elsewhere in the cabin, and are reset once faded. --- src/effects/Effect.hpp | 6 ++++++ src/effects/EffectManager.cpp | 17 ++++++++++++++++- src/effects/EffectManager.hpp | 10 +++++++++- src/effects/driving/BodyDynamicsEffect.hpp | 1 + src/effects/driving/HeadMotionEffect.hpp | 1 + src/effects/driving/SteeringCameraEffect.hpp | 1 + src/effects/manual/BlindspotViewerEffect.hpp | 1 + src/effects/manual/ManualLookEffect.hpp | 1 + src/effects/manual/MirrorCheckEffect.hpp | 1 + src/effects/road/RoadIrregularityEffect.hpp | 3 +++ src/effects/road/SpeedShakeEffect.hpp | 5 ++++- src/effects/road/SuspensionEffect.hpp | 3 +++ 12 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/effects/Effect.hpp b/src/effects/Effect.hpp index 88081d2..e0cb53d 100644 --- a/src/effects/Effect.hpp +++ b/src/effects/Effect.hpp @@ -40,6 +40,12 @@ class Effect { virtual HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) = 0; + // True for the effects that only make sense in the driver's seat (driving + // motion, looking at the mirrors, road motion, ...): they fade out while + // Cabin Walk takes the player elsewhere in the cabin + // (EffectManager::SetAtWheel). + virtual bool NeedsDriverSeat() const { return false; } + // 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 76bdd7c..51944e6 100644 --- a/src/effects/EffectManager.cpp +++ b/src/effects/EffectManager.cpp @@ -19,16 +19,31 @@ void EffectManager::ResetAll() { for (auto &slot : effects_) { slot.effect->Reset(); slot.fade = slot.effect->IsEnabled() ? 1.0f : 0.0f; + slot.seat_reset = false; } + seat_fade_ = at_wheel_ ? 1.0f : 0.0f; } HeadOffset EffectManager::UpdateAndAccumulate(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) { const float step = dt / kFadeSeconds; + seat_fade_ = at_wheel_ ? std::min(1.0f, seat_fade_ + step) + : std::max(0.0f, seat_fade_ - step); HeadOffset total; for (auto &slot : effects_) { Effect &effect = *slot.effect; + float seat_weight = 1.0f; + if (effect.NeedsDriverSeat()) { + if (seat_fade_ <= 0.0f) { + if (!slot.seat_reset) + effect.Reset(); // start from rest back in the seat + slot.seat_reset = true; + continue; + } + slot.seat_reset = false; + seat_weight = math::SmoothStep(seat_fade_); + } if (effect.IsEnabled()) { slot.fade = std::min(1.0f, slot.fade + step); } else { @@ -41,7 +56,7 @@ HeadOffset EffectManager::UpdateAndAccumulate(float dt, } } const HeadOffset contribution = effect.Update(dt, truck, controls); - const float weight = math::SmoothStep(slot.fade); + const float weight = math::SmoothStep(slot.fade) * seat_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 7911889..c30fef8 100644 --- a/src/effects/EffectManager.hpp +++ b/src/effects/EffectManager.hpp @@ -24,6 +24,11 @@ class EffectManager { // disabled ones), since the camera is being resynced anyway. void ResetAll(); + // Whether the player is in the driver's seat. Effects that need it + // (Effect::NeedsDriverSeat) fade out over kFadeSeconds when they leave + // it, and are Reset() once faded out, so they come back from rest. + void SetAtWheel(bool at_wheel) { at_wheel_ = at_wheel; } + // Advances every enabled (or still fading out) effect and returns the // summed, fade-weighted offset to apply. HeadOffset UpdateAndAccumulate(float dt, const SPF_TruckData &truck, @@ -45,9 +50,12 @@ class EffectManager { struct Slot { std::unique_ptr effect; - float fade = 1.0f; // 0 = contributes nothing, 1 = full contribution + float fade = 1.0f; // 0 = contributes nothing, 1 = full contribution + bool seat_reset = false; // Reset() since the driver's seat was left }; std::vector effects_; + bool at_wheel_ = true; + float seat_fade_ = 1.0f; // same as Slot::fade, for NeedsDriverSeat effects }; } // namespace motioncab diff --git a/src/effects/driving/BodyDynamicsEffect.hpp b/src/effects/driving/BodyDynamicsEffect.hpp index 7273897..ee0471f 100644 --- a/src/effects/driving/BodyDynamicsEffect.hpp +++ b/src/effects/driving/BodyDynamicsEffect.hpp @@ -22,6 +22,7 @@ class BodyDynamicsEffect final : public ConfigurableEffect { "body_dynamics") {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/driving/HeadMotionEffect.hpp b/src/effects/driving/HeadMotionEffect.hpp index 0ebd444..1b144c2 100644 --- a/src/effects/driving/HeadMotionEffect.hpp +++ b/src/effects/driving/HeadMotionEffect.hpp @@ -20,6 +20,7 @@ class HeadMotionEffect final : public ConfigurableEffect { "head_motion") {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/driving/SteeringCameraEffect.hpp b/src/effects/driving/SteeringCameraEffect.hpp index 6d62a47..8fb012a 100644 --- a/src/effects/driving/SteeringCameraEffect.hpp +++ b/src/effects/driving/SteeringCameraEffect.hpp @@ -39,6 +39,7 @@ class SteeringCameraEffect final : public ConfigurableEffect { "steering_camera") {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/manual/BlindspotViewerEffect.hpp b/src/effects/manual/BlindspotViewerEffect.hpp index 573bf31..9695c4e 100644 --- a/src/effects/manual/BlindspotViewerEffect.hpp +++ b/src/effects/manual/BlindspotViewerEffect.hpp @@ -32,6 +32,7 @@ class BlindspotViewerEffect final : public ConfigurableEffect { keybinds_api_(keybinds_api), keybinds_handle_(keybinds_handle) {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/manual/ManualLookEffect.hpp b/src/effects/manual/ManualLookEffect.hpp index 5f7543c..4c3466e 100644 --- a/src/effects/manual/ManualLookEffect.hpp +++ b/src/effects/manual/ManualLookEffect.hpp @@ -27,6 +27,7 @@ class ManualLookEffect final : public ConfigurableEffect { keybinds_api_(keybinds_api), keybinds_handle_(keybinds_handle) {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/manual/MirrorCheckEffect.hpp b/src/effects/manual/MirrorCheckEffect.hpp index cc21085..61b2cf7 100644 --- a/src/effects/manual/MirrorCheckEffect.hpp +++ b/src/effects/manual/MirrorCheckEffect.hpp @@ -27,6 +27,7 @@ class MirrorCheckEffect final : public ConfigurableEffect { "mirror_check") {} void Reset() override; + bool NeedsDriverSeat() const override { return true; } HeadOffset Update(float dt, const SPF_TruckData &truck, const SPF_Controls &controls) override; diff --git a/src/effects/road/RoadIrregularityEffect.hpp b/src/effects/road/RoadIrregularityEffect.hpp index c338c8d..2a041dc 100644 --- a/src/effects/road/RoadIrregularityEffect.hpp +++ b/src/effects/road/RoadIrregularityEffect.hpp @@ -24,6 +24,9 @@ class RoadIrregularityEffect final : public ConfigurableEffect { SPF_Config_Handle *config_handle); void Reset() override; + // Road motion is for driving: standing in the parked truck, it'd be idle + // noise. + 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; diff --git a/src/effects/road/SpeedShakeEffect.hpp b/src/effects/road/SpeedShakeEffect.hpp index cc97a53..bee9891 100644 --- a/src/effects/road/SpeedShakeEffect.hpp +++ b/src/effects/road/SpeedShakeEffect.hpp @@ -31,6 +31,9 @@ class SpeedShakeEffect final : public ConfigurableEffect { SPF_Config_Handle *config_handle); void Reset() override; + // Road motion is for driving: standing in the parked truck, it'd be idle + // noise. + 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; @@ -64,7 +67,7 @@ class SpeedShakeEffect final : public ConfigurableEffect { // up a new stretch of noise instead of replaying the same one. // Both wrapped with math::WrapNoisePhase. std::array phase_{}; - double envelope_phase_ = 0.0; // not reset: keeps the roughness varying + double envelope_phase_ = 0.0; // not reset: keeps the roughness varying math::SpringDamper1D entry_fade_; // 0 -> 1 after Reset(), no pop math::SpringDamper1D surface_gain_; // amplitude factor from the ground }; diff --git a/src/effects/road/SuspensionEffect.hpp b/src/effects/road/SuspensionEffect.hpp index 095db87..60230ae 100644 --- a/src/effects/road/SuspensionEffect.hpp +++ b/src/effects/road/SuspensionEffect.hpp @@ -30,6 +30,9 @@ class SuspensionEffect final : public ConfigurableEffect { : ConfigurableEffect(config_api, config_handle, "road", "suspension") {} void Reset() override; + // Road motion is for driving: standing in the parked truck, it'd be idle + // noise. + 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; From 5c4e39bc0dcd14c8a0bdd2bdc64b227f0127b846 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:46:40 +0200 Subject: [PATCH 3/5] feat: add Cabin Walk to walk around the parked cabin Get up from the driver's seat with the parking brake on, walk the cabin with WASD, crouch, and sit in the passenger seat or on the bunk, with footstep sounds. Each truck model has a hand-calibrated cabin layout, adjustable from the Quick Settings window. Based on SPF_CabinWalk. --- CMakeLists.txt | 6 + data/sounds/MotionCab.bank | Bin 0 -> 14112 bytes data/sounds/MotionCab.bank.guids | 6 + localization/cs.json | 95 +- localization/de.json | 95 +- localization/en.json | 95 +- localization/es.json | 95 +- localization/fr.json | 95 +- localization/it.json | 95 +- localization/ja.json | 95 +- localization/ko.json | 95 +- localization/nl.json | 95 +- localization/pl.json | 95 +- localization/pt_br.json | 95 +- localization/ru.json | 95 +- localization/tr.json | 95 +- localization/uk.json | 95 +- localization/zh.json | 95 +- src/Plugin.cpp | 108 ++- src/core/CameraRig.cpp | 20 + src/core/CameraRig.hpp | 15 + src/core/Keybinds.hpp | 52 +- src/core/Manifest.cpp | 5 +- src/core/PluginContext.cpp | 2 + src/core/PluginContext.hpp | 17 +- src/core/SettingsSchema.hpp | 3 + .../manual/cabin_walk/CabinLayouts.cpp | 174 ++++ .../manual/cabin_walk/CabinLayouts.hpp | 125 +++ .../manual/cabin_walk/CabinPresets.cpp | 884 ++++++++++++++++++ .../manual/cabin_walk/CabinPresets.hpp | 23 + .../manual/cabin_walk/CabinWalkEffect.cpp | 795 ++++++++++++++++ .../manual/cabin_walk/CabinWalkEffect.hpp | 275 ++++++ .../manual/cabin_walk/CabinWalkSounds.cpp | 81 ++ .../manual/cabin_walk/CabinWalkSounds.hpp | 48 + .../cabin_walk/InteriorCameraOverride.cpp | 154 +++ .../cabin_walk/InteriorCameraOverride.hpp | 110 +++ src/ui/CabinLayoutPanel.cpp | 221 +++++ src/ui/CabinLayoutPanel.hpp | 12 + src/ui/EffectTabs.cpp | 34 +- src/ui/Widgets.cpp | 52 ++ src/ui/Widgets.hpp | 19 + 41 files changed, 4619 insertions(+), 47 deletions(-) create mode 100644 data/sounds/MotionCab.bank create mode 100644 data/sounds/MotionCab.bank.guids create mode 100644 src/effects/manual/cabin_walk/CabinLayouts.cpp create mode 100644 src/effects/manual/cabin_walk/CabinLayouts.hpp create mode 100644 src/effects/manual/cabin_walk/CabinPresets.cpp create mode 100644 src/effects/manual/cabin_walk/CabinPresets.hpp create mode 100644 src/effects/manual/cabin_walk/CabinWalkEffect.cpp create mode 100644 src/effects/manual/cabin_walk/CabinWalkEffect.hpp create mode 100644 src/effects/manual/cabin_walk/CabinWalkSounds.cpp create mode 100644 src/effects/manual/cabin_walk/CabinWalkSounds.hpp create mode 100644 src/effects/manual/cabin_walk/InteriorCameraOverride.cpp create mode 100644 src/effects/manual/cabin_walk/InteriorCameraOverride.hpp create mode 100644 src/ui/CabinLayoutPanel.cpp create mode 100644 src/ui/CabinLayoutPanel.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4cd3192..085c619 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,9 +77,15 @@ set(PLUGIN_SOURCES "src/effects/cabin/EngineStartStopEffect.cpp" "src/effects/manual/ManualZoomEffect.cpp" "src/effects/manual/BlindspotViewerEffect.cpp" + "src/effects/manual/cabin_walk/CabinWalkEffect.cpp" + "src/effects/manual/cabin_walk/CabinLayouts.cpp" + "src/effects/manual/cabin_walk/CabinPresets.cpp" + "src/effects/manual/cabin_walk/CabinWalkSounds.cpp" + "src/effects/manual/cabin_walk/InteriorCameraOverride.cpp" "src/ui/SettingsWindow.cpp" "src/ui/Widgets.cpp" "src/ui/EffectTabs.cpp" + "src/ui/CabinLayoutPanel.cpp" "src/ui/SettingsTab.cpp" "src/ui/AboutTab.cpp" "src/ui/OpenUrl.cpp" diff --git a/data/sounds/MotionCab.bank b/data/sounds/MotionCab.bank new file mode 100644 index 0000000000000000000000000000000000000000..b632d53893a908f55719aea89698b61ba644bf63 GIT binary patch literal 14112 zcmdUWcU%+Ox9=oC2%#7{0!pY-LN8K8A%sqVKmq~jO^ASW5gSc}P^5Q}UPA9hL_nGp z4^@%kK@g;fy&ub)9FOPxuD85HBP%XN3`DJOf zX}xq|y|4{s4p~bD2id_%wMMO}iuQ?v_@T)<3l} z{7q3EfHZ5%TP_|Z{bXPARJHI0!(X+c-R(cq z3JR9}S6b0Dw4!PCx3+}8wZ;7>+TuWM!LmQK{Z%(Unlk?e|F5;h=o^s!*7jd$>wl&7 z{*ke^{)e{F)BV%BAy7ko8_?2UdPv=$d~*U=hRFcw39mM%*N5y(YM#6o8mMpR`!|Mx z2mAi3H5NQqu~lIsHc6~;%)j=iUu%cfPa=O_gw~mUfYwMf=nB#r5()g;1!xTj0FfsE zpjh|c^(%j{h+t;^?`*N5@o{G6U>QaQq<_^F0^W)bFh$j2b*kiX`OAdW`@2cMfr7wG zS=ye~Z+_M@!OGlPk6>l-qc{EJTH?(9rQhKGra{SnXe*n)X`H3upR^ok4UTC1?=
d#*4neKjTUdhy8oJS<~j&zs8=K?T=Zl8kD!y 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 +#include #include #include #include @@ -53,13 +56,25 @@ void OnControlsUpdate(const SPF_Controls *data, void * /*user_data*/) { void OnTruckConstantsUpdate(const SPF_TruckConstants *data, void * /*user_data*/) { - Context().effects.NotifyTruckConstants(*data); + PluginContext &ctx = Context(); + // Another truck (or none, in between two): the game rebuilds the interior + // camera with the player's own pose, which holds none of our offset. + const std::string truck = std::string(data->brand_id) + "/" + data->id; + if (truck != ctx.truck_identity) { + ctx.truck_identity = truck; + ctx.camera_rig.ForgetAppliedPose(); + ctx.ResetEffects(); + } + ctx.effects.NotifyTruckConstants(*data); + if (ctx.cabin_walk) { + ctx.cabin_walk->OnTruckConstantsChanged(*data); + } - // Don't call Cam_SwitchTo here (even deferred): SPF-Framework's native - // camera hooks already refresh the pointer via OnActivate/OnDeactivate - // during truck reconfiguration, and the interior pos/rot getters are - // null-safe meanwhile. Cam_SwitchTo bypasses that guard and can crash - // during slow Quick Job reloads (750ms+), so no fixed delay is safe. + // Don't call Cam_SwitchTo here (even deferred) to get SPF onto the new + // truck's camera: it can crash during slow Quick Job reloads (750ms+), + // so no fixed delay is safe. Picking up the rebuilt camera is SPF's job: + // older releases only did it on a view change, so a truck switch in the + // interior view left them writing to the freed camera. } void OnCommonDataUpdate(const SPF_CommonData *data, void * /*user_data*/) { @@ -76,6 +91,27 @@ void OnTrailersUpdate(const SPF_Trailer *trailers, uint32_t count, // unregistered action stays stuck at 0.0 even with a valid manifest binding. void OnPolledActionTriggered() {} +// Tells the player why Cabin Walk refused to stand up, or sent them back to +// the wheel. +void ShowCabinWalkNotice(PluginContext &ctx) { + const char *key = nullptr; + switch (ctx.cabin_walk->TakeNotice()) { + case CabinWalkEffect::Notice::kNeedsParkingBrake: + key = "ui.cabin_walk.needs_parking_brake"; + break; + case CabinWalkEffect::Notice::kNeedsStop: + key = "ui.cabin_walk.needs_stop"; + break; + case CabinWalkEffect::Notice::kBackToWheel: + key = "ui.cabin_walk.back_to_wheel"; + break; + case CabinWalkEffect::Notice::kNone: + break; + } + if (key && ctx.core->ui) + ui::ShowToast(ctx.core->ui, SPF_NOTIFICATION_WARNING, loc::Tr(key)); +} + float ComputeDeltaTimeSeconds(PluginContext &ctx) { const auto now = std::chrono::steady_clock::now(); if (!ctx.has_last_update_time) { @@ -108,10 +144,15 @@ void OnUnload() { Context().manual_zoom->RestoreFov(); Context().manual_zoom->RestoreDynamicFov(); } + // Give the camera's own limits and the mouse back if the player is up in + // the cabin. Their position is part of the offset below. + PluginContext &ctx = Context(); + if (ctx.cabin_walk) + ctx.cabin_walk->Shutdown(ctx.core ? ctx.core->camera : nullptr); + ctx.cabin_walk_sounds.Shutdown(ctx.core ? ctx.core->sound : nullptr); // Take our offset back out of the interior pose, which the engine keeps // after we're gone. After ManualZoomEffect's RestoreFov above, which // adds the applied FOV offset back on. - PluginContext &ctx = Context(); if (ctx.core && ctx.core->camera) ctx.camera_rig.Remove(ctx.core->camera); // The About tab's logo texture is the one thing this plugin allocates @@ -146,6 +187,16 @@ void OnToggleSettingsWindow() { ctx.core->config->Cfg_SetBool(ctx.config_handle, kKey, new_state); } +// `file` in the plugin's data directory, next to the profiles; empty (the +// file then isn't used) if the data directory isn't available. +std::string DataFilePath(PluginContext &ctx, const char *file) { + const std::string dir = ctx.PluginDataDir(); + if (dir.empty() || !ctx.core->environment->Env_CreatePath( + ctx.environment_handle, dir.c_str())) + return {}; + return dir + "/" + file; +} + void OnActivated(const SPF_Core_API *core_api) { PluginContext &ctx = Context(); ctx.core = core_api; @@ -198,6 +249,14 @@ void OnActivated(const SPF_Core_API *core_api) { ctx.manual_zoom = std::make_unique( core_api->config, ctx.config_handle, core_api->keybinds, ctx.keybinds_handle, &ctx.camera_rig.applied().fov); + // The player's own layouts (the presets are compiled in). + ctx.cabin_layouts = std::make_unique( + core_api->config, DataFilePath(ctx, "cabin_layouts.json")); + if (const std::string dir = ctx.PluginDataDir(); !dir.empty()) + ctx.cabin_walk_sounds.SetDirectory(dir + "/sounds"); + ctx.cabin_walk = std::make_unique( + core_api->config, ctx.config_handle, core_api->keybinds, + ctx.keybinds_handle, core_api->ui, ctx.cabin_layouts.get()); ctx.ReloadEffectsConfig(); ctx.telemetry_handle = @@ -216,6 +275,7 @@ void OnActivated(const SPF_Core_API *core_api) { core_api->telemetry->Tel_GetTruckConstants( ctx.telemetry_handle, &initial_constants, sizeof(initial_constants)); ctx.effects.NotifyTruckConstants(initial_constants); + ctx.cabin_walk->OnTruckConstantsChanged(initial_constants); core_api->telemetry->Tel_RegisterForCommonData(ctx.telemetry_handle, OnCommonDataUpdate, nullptr); core_api->telemetry->Tel_RegisterForTrailers(ctx.telemetry_handle, @@ -281,6 +341,8 @@ void OnUpdate() { if (!is_interior) { if (ctx.was_interior_last_frame && ctx.manual_zoom) ctx.manual_zoom->RestoreFov(); + if (ctx.cabin_walk) + ctx.cabin_walk->OnLeftInterior(); ctx.was_interior_last_frame = false; ctx.has_last_update_time = false; return; @@ -310,19 +372,45 @@ void OnUpdate() { // Differential write (see CameraRig), skipped entirely while the camera // isn't resolved yet. SPF_Camera_API *camera = ctx.core->camera; - const std::optional pose = ctx.camera_rig.ReadPose(camera); + std::optional pose = ctx.camera_rig.ReadPose(camera); if (!pose) return; if (ctx.camera_rig.DetectNativeRecenter(camera, *pose)) ctx.effects.ResetAll(); ctx.camera_rig.DetectExternalSeatWrite(*pose); - const HeadOffset offset = ctx.effects.UpdateAndAccumulate( - dt, ctx.latest_truck_data, ctx.latest_controls_data); + + // Cabin Walk first: whether the player is at the wheel decides which + // effects play, and sitting down turns the player's own view. + HeadOffset walk{}; + if (ctx.cabin_walk) { + walk = ctx.cabin_walk->Update(dt, ctx.latest_truck_data, camera, + ctx.camera_rig.Base(*pose)); + if (const auto &rotation = ctx.cabin_walk->base_rotation()) + ctx.camera_rig.SetBaseRotation(*pose, rotation->first, rotation->second); + ctx.effects.SetAtWheel(ctx.cabin_walk->AtWheel()); + ShowCabinWalkNotice(ctx); + ctx.cabin_walk_sounds.Update(ctx.core->sound); + for (float volume : ctx.cabin_walk->TakeFootsteps()) + ctx.cabin_walk_sounds.PlayFootstep(ctx.core->sound, volume); + } + + HeadOffset offset = ctx.effects.UpdateAndAccumulate(dt, ctx.latest_truck_data, + ctx.latest_controls_data); + offset.pos_x += walk.pos_x; + offset.pos_y += walk.pos_y; + offset.pos_z += walk.pos_z; + offset.yaw += walk.yaw; + offset.pitch += walk.pitch; + offset.roll += walk.roll; + offset.fov += walk.fov; ctx.camera_rig.Apply(camera, *pose, offset); } void OnGameWorldReady() { PluginContext &ctx = Context(); + // A new world starts in the driver's seat, with a new camera. + if (ctx.cabin_walk) + ctx.cabin_walk->SnapToWheel(); ctx.was_interior_last_frame = false; ctx.has_last_update_time = false; ctx.has_truck_data.store(false, std::memory_order_relaxed); diff --git a/src/core/CameraRig.cpp b/src/core/CameraRig.cpp index 8ab2202..385e63a 100644 --- a/src/core/CameraRig.cpp +++ b/src/core/CameraRig.cpp @@ -78,6 +78,26 @@ bool CameraRig::DetectExternalSeatWrite(const Pose &pose) { return any; } +CameraRig::Pose CameraRig::Base(const Pose &pose) const { + return {pose.seat_x - applied_.pos_x, pose.seat_y - applied_.pos_y, + pose.seat_z - applied_.pos_z, pose.yaw_rad - applied_.yaw * kDegToRad, + pose.pitch_rad - applied_.pitch * kDegToRad}; +} + +void CameraRig::SetBaseRotation(Pose &pose, float yaw_rad, + float pitch_rad) const { + pose.yaw_rad = yaw_rad + applied_.yaw * kDegToRad; + pose.pitch_rad = pitch_rad + applied_.pitch * kDegToRad; +} + +void CameraRig::ForgetAppliedPose() { + const float roll = applied_.roll; + applied_ = {}; + applied_.roll = roll; + has_last_written_rot_ = false; + has_last_written_seat_ = false; +} + void CameraRig::Apply(SPF_Camera_API *camera, const Pose &pose, const HeadOffset &offset) { WriteSeat(camera, pose.seat_x, pose.seat_y, pose.seat_z, offset); diff --git a/src/core/CameraRig.hpp b/src/core/CameraRig.hpp index 9d9128f..92454eb 100644 --- a/src/core/CameraRig.hpp +++ b/src/core/CameraRig.hpp @@ -44,6 +44,15 @@ class CameraRig { // offset that's no longer there subtracted from it. bool DetectExternalSeatWrite(const Pose &pose); + // The player's own pose underneath: `pose` minus the applied offset. + Pose Base(const Pose &pose) const; + + // Makes the player's own head rotation under `pose` `yaw_rad`/ + // `pitch_rad`, so the next Apply writes that plus the new offset. For + // turning the player's view itself rather than adding to it (Cabin Walk + // sitting down), with the mouse blocked meanwhile. + void SetBaseRotation(Pose &pose, float yaw_rad, float pitch_rad) const; + // Replaces last frame's offset with `offset` in the live pose. void Apply(SPF_Camera_API *camera, const Pose &pose, const HeadOffset &offset); @@ -54,6 +63,12 @@ class CameraRig { // the seat leaned forward for good. void Remove(SPF_Camera_API *camera); + // The game rebuilt the interior camera (another truck): the new one holds + // the player's own pose, without our offset, so there's nothing to + // subtract any more. Roll is kept: SPF remembers the roll it was given + // and puts it back on the new camera. + void ForgetAppliedPose(); + // The offset currently in the live pose. const HeadOffset &applied() const { return applied_; } diff --git a/src/core/Keybinds.hpp b/src/core/Keybinds.hpp index 826f6f8..6c07a3f 100644 --- a/src/core/Keybinds.hpp +++ b/src/core/Keybinds.hpp @@ -2,6 +2,8 @@ #include "SPF_KeyBinds_API.h" +#include + // MotionCab's keybind actions, declared once for the manifest (group + // name, default key, title/description), the registration in Plugin.cpp // and the effects/UI (full "Group.name" id). @@ -28,15 +30,49 @@ inline constexpr Action kBlindspotPeek{"BlindspotViewer", "peek", "keybinds.blindspot_viewer", "KEY_F10"}; inline constexpr Action kToggleWindow{"UI", "toggle", "UI.toggle", "keybinds.ui_toggle", "KEY_F9"}; +inline constexpr Action kStandSit{ + "CabinWalk", "stand_sit", "CabinWalk.stand_sit", + "keybinds.cabin_walk.stand_sit", "KEY_PAGEUP"}; +// WASD, shared with driving: CabinWalkEffect takes them away from the game +// (Kbind_SetBlockState) only while the player is out of the driver's seat. +inline constexpr Action kWalkForward{"CabinWalk", "forward", + "CabinWalk.forward", + "keybinds.cabin_walk.forward", "KEY_W"}; +inline constexpr Action kWalkBack{"CabinWalk", "back", "CabinWalk.back", + "keybinds.cabin_walk.back", "KEY_S"}; +inline constexpr Action kWalkLeft{"CabinWalk", "left", "CabinWalk.left", + "keybinds.cabin_walk.left", "KEY_A"}; +inline constexpr Action kWalkRight{"CabinWalk", "right", "CabinWalk.right", + "keybinds.cabin_walk.right", "KEY_D"}; +// C rather than Ctrl/Space: SPF never takes a modifier key away from the +// game, and Space releases the parking brake, which sends the player back to +// the wheel. C is the game's cruise control, so it's taken away like WASD. +inline constexpr Action kCrouch{"CabinWalk", "crouch", "CabinWalk.crouch", + "keybinds.cabin_walk.crouch", "KEY_C"}; // Every action, in the order the manifest declares them. -inline constexpr Action kAllActions[] = {kLookLeft, kLookRight, kZoom, - kBlindspotPeek, kToggleWindow}; +inline constexpr Action kAllActions[] = { + kLookLeft, kLookRight, kZoom, kBlindspotPeek, + kToggleWindow, kStandSit, kWalkForward, kWalkBack, + kWalkLeft, kWalkRight, kCrouch}; // Actions the effects poll every frame via IsHeld() instead of reacting to // a Kbind_Register callback. -inline constexpr Action kPolledActions[] = {kLookLeft, kLookRight, kZoom, - kBlindspotPeek}; +inline constexpr Action kPolledActions[] = { + kLookLeft, kLookRight, kZoom, kBlindspotPeek, kStandSit, + kWalkForward, kWalkBack, kWalkLeft, kWalkRight, kCrouch}; + +// Actions on keys the game drives with: declared with the "manual" consume +// policy, so CabinWalkEffect decides when the game stops seeing them. +inline constexpr Action kWalkActions[] = {kWalkForward, kWalkBack, kWalkLeft, + kWalkRight, kCrouch}; + +inline bool IsWalkAction(const Action &action) { + for (const Action &walk : kWalkActions) + if (std::strcmp(walk.id, action.id) == 0) + return true; + return false; +} // Immediate physical state of the action's key, ignoring any hold/toggle // behavior configured for the binding itself: MotionCab implements its own. @@ -45,6 +81,14 @@ inline bool IsHeld(SPF_KeyBinds_API *api, SPF_KeyBinds_Handle *handle, return api->Kbind_GetActionValue(handle, action.id) > 0.5f; } +// The action's value in [0, 1]: 0/1 for a key, the deflection for an +// analog axis bound to it. +inline float Amount(SPF_KeyBinds_API *api, SPF_KeyBinds_Handle *handle, + const Action &action) { + const float value = api->Kbind_GetActionValue(handle, action.id); + return value < 0.0f ? 0.0f : (value > 1.0f ? 1.0f : value); +} + // Turns a held state into a one-frame "just pressed" edge. class PressEdge { public: diff --git a/src/core/Manifest.cpp b/src/core/Manifest.cpp index 429f268..eef3892 100644 --- a/src/core/Manifest.cpp +++ b/src/core/Manifest.cpp @@ -86,8 +86,9 @@ void BuildManifest(SPF_Manifest_Builder_Handle *h, api->Defaults_SetLogging(h, "info", true); api->Defaults_SetLocalization(h, "en"); for (const keybinds::Action &action : keybinds::kAllActions) - api->Defaults_AddKeybind(h, action.group, action.name, "keyboard", - action.default_key, "always"); + api->Defaults_AddKeybind( + h, action.group, action.name, "keyboard", action.default_key, + keybinds::IsWalkAction(action) ? "manual" : "always"); // isVisible=true here only decides the very first launch ever api->Defaults_AddWindow(h, "MotionCab", true, true, 100, 100, kWindowWidth, kWindowHeight, false, true); diff --git a/src/core/PluginContext.cpp b/src/core/PluginContext.cpp index a3f6df8..27a1723 100644 --- a/src/core/PluginContext.cpp +++ b/src/core/PluginContext.cpp @@ -12,6 +12,8 @@ void PluginContext::ReloadEffectsConfig() { effects.LoadAllConfig(); if (manual_zoom) manual_zoom->LoadConfig(); + if (cabin_walk) + cabin_walk->LoadConfig(); } void PluginContext::ResetEffects() { diff --git a/src/core/PluginContext.hpp b/src/core/PluginContext.hpp index 1168de9..d6f83e6 100644 --- a/src/core/PluginContext.hpp +++ b/src/core/PluginContext.hpp @@ -12,6 +12,9 @@ #include "core/CameraRig.hpp" #include "effects/EffectManager.hpp" #include "effects/manual/ManualZoomEffect.hpp" +#include "effects/manual/cabin_walk/CabinLayouts.hpp" +#include "effects/manual/cabin_walk/CabinWalkEffect.hpp" +#include "effects/manual/cabin_walk/CabinWalkSounds.hpp" #include #include @@ -35,6 +38,10 @@ struct PluginContext { EffectManager effects; std::unique_ptr manual_zoom; + // Held apart like manual_zoom, see its class comment. + std::unique_ptr cabin_layouts; + std::unique_ptr cabin_walk; + CabinWalkSounds cabin_walk_sounds; // Latest truck telemetry snapshot, refreshed via Tel_RegisterForTruckData. SPF_TruckData latest_truck_data{}; @@ -49,16 +56,22 @@ struct PluginContext { // Writes the effects' offset into the interior camera (differential // write) and remembers what it applied. CameraRig camera_rig; + // "/" of the last truck telemetry described, to tell a + // truck switch from a change to the same truck. + std::string truck_identity; bool was_interior_last_frame = false; std::chrono::steady_clock::time_point last_update_time{}; bool has_last_update_time = false; - // Re-reads every effect's settings, ManualZoomEffect's included. + // Re-reads every effect's settings, ManualZoomEffect's and + // CabinWalkEffect's included. void ReloadEffectsConfig(); // Clears every effect's smoothing state (settling their fades, see - // EffectManager::ResetAll), ManualZoomEffect's included. + // EffectManager::ResetAll), ManualZoomEffect's included. Not Cabin + // Walk's: a player up in the cabin stays there across a view switch or a + // recenter. void ResetEffects(); // The plugin's data directory (Env_GetPluginDataDir), or empty if the diff --git a/src/core/SettingsSchema.hpp b/src/core/SettingsSchema.hpp index ac2a607..5387684 100644 --- a/src/core/SettingsSchema.hpp +++ b/src/core/SettingsSchema.hpp @@ -154,6 +154,9 @@ inline constexpr Setting kAll[] = { Float("settings.manual.blindspot_viewer.smoothing_time", 0.6f, 0.2f, 1.5f, "%.2f s"), Bool("settings.manual.blindspot_viewer.toggle_mode", true), + // manual.cabin_walk + Bool("settings.manual.cabin_walk.enabled", true), + Bool("settings.manual.cabin_walk.require_parking_brake", true), }; // "settings..." split into its parts. diff --git a/src/effects/manual/cabin_walk/CabinLayouts.cpp b/src/effects/manual/cabin_walk/CabinLayouts.cpp new file mode 100644 index 0000000..3814229 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinLayouts.cpp @@ -0,0 +1,174 @@ +#include "CabinLayouts.hpp" + +#include "CabinPresets.hpp" + +#include +#include +#include +#include +#include +#include + +namespace motioncab { + +namespace { + +using L = CabinLayout; +// The ranges the sliders allow, meters or degrees. +constexpr CabinLayoutField kFields[] = { + {"floor_x_min", &L::floor_x_min, -1.5f, 2.0f, false}, + {"floor_x_max", &L::floor_x_max, -1.5f, 2.0f, false}, + {"floor_z_min", &L::floor_z_min, -1.5f, 2.5f, false}, + {"floor_z_max", &L::floor_z_max, -1.5f, 2.5f, false}, + {"stand_y", &L::stand_y, -0.5f, 1.0f, false}, + {"crouch_depth", &L::crouch_depth, 0.1f, 0.8f, false}, + {"passenger_dx", &L::passenger_dx, -0.4f, 0.4f, false}, + {"passenger_dy", &L::passenger_dy, -0.4f, 0.4f, false}, + {"passenger_dz", &L::passenger_dz, -0.4f, 0.4f, false}, + {"passenger_yaw", &L::passenger_yaw, -180.0f, 180.0f, true}, + {"passenger_pitch", &L::passenger_pitch, -80.0f, 80.0f, true}, + {"bunk_sit_x", &L::bunk_sit_x, -1.5f, 2.0f, false}, + {"bunk_sit_y", &L::bunk_sit_y, -1.0f, 1.0f, false}, + {"bunk_sit_z", &L::bunk_sit_z, -1.5f, 2.5f, false}, + {"bunk_sit_yaw", &L::bunk_sit_yaw, -180.0f, 180.0f, true}, + {"bunk_sit_pitch", &L::bunk_sit_pitch, -80.0f, 80.0f, true}, +}; + +std::string Key(const std::string &truck_key, const char *field) { + return "layouts." + truck_key + "." + field; +} + +} // namespace + +float CabinCenterlineX(const SPF_TruckConstants &constants) { + const float head_x = constants.head_position.x; + // Whichever side the driver sits on: the camera frame of a right-hand + // drive truck is mirrored (see CabinLayout). + return std::fabs(head_x) >= kMinHeadOffsetX ? std::fabs(head_x) + : kTypicalCenterlineX; +} + +CabinLayout DefaultCabinLayout(float centerline_x) { + const float c = centerline_x; + constexpr float kWalkwayHalfWidth = 0.2f; + CabinLayout l{}; + l.floor_x_min = c - kWalkwayHalfWidth; + l.floor_x_max = c + kWalkwayHalfWidth; + l.floor_z_min = -0.5f; + l.floor_z_max = 0.6f; + l.stand_y = 0.2f; + l.crouch_depth = 0.45f; + l.passenger_dx = 0.0f; + l.passenger_dy = 0.0f; + l.passenger_dz = 0.0f; + l.passenger_yaw = 0.0f; + l.passenger_pitch = 0.0f; + l.bunk_sit_x = c; + l.bunk_sit_y = 0.0f; + l.bunk_sit_z = 0.8f; + l.bunk_sit_yaw = 0.0f; + l.bunk_sit_pitch = 0.0f; + return l; +} + +std::span CabinLayoutFields() { return kFields; } + +CabinLayout WithMirroredYaws(const CabinLayout &layout) { + CabinLayout m = layout; + m.passenger_yaw = -layout.passenger_yaw; + m.bunk_sit_yaw = -layout.bunk_sit_yaw; + return m; +} + +void NormalizeCabinLayout(CabinLayout &layout) { + if (layout.floor_x_min > layout.floor_x_max) + std::swap(layout.floor_x_min, layout.floor_x_max); + if (layout.floor_z_min > layout.floor_z_max) + std::swap(layout.floor_z_min, layout.floor_z_max); +} + +std::string TruckLayoutKey(const SPF_TruckConstants &constants) { + auto clean = [](const char *s) { + std::string out; + for (; *s; ++s) + out += std::isalnum(static_cast(*s)) ? *s : '_'; + return out; + }; + const std::string brand = clean(constants.brand_id); + const std::string model = clean(constants.id); + if (brand.empty() && model.empty()) + return {}; + return brand + "_" + model; +} + +SPF_Config_Handle *CabinLayoutStore::Handle(bool create) { + if (handle_ || !config_ || path_.empty()) + return handle_; + // Opening a custom context creates a missing file: only once there's a + // layout to write. + std::error_code ec; + if (create || std::filesystem::exists(path_, ec)) + handle_ = config_->Cfg_CreateCustomContext(path_.c_str()); + return handle_; +} + +std::optional +CabinLayoutStore::Read(SPF_Config_Handle *h, + const std::string &truck_key) const { + // A layout always has its walkway (Save writes every field); + // an emptied one (see Forget) doesn't. + if (!h || !config_->Cfg_HasKey(h, Key(truck_key, "floor_x_min").c_str())) + return std::nullopt; + CabinLayout layout = DefaultCabinLayout(); + for (const CabinLayoutField &f : kFields) + layout.*f.member = static_cast(config_->Cfg_GetFloat( + h, Key(truck_key, f.name).c_str(), layout.*f.member)); + layout.has_passenger = config_->Cfg_GetBool( + h, Key(truck_key, "has_passenger").c_str(), layout.has_passenger); + layout.has_bunk = config_->Cfg_GetBool(h, Key(truck_key, "has_bunk").c_str(), + layout.has_bunk); + NormalizeCabinLayout(layout); + return layout; +} + +std::optional +CabinLayoutStore::LoadCustom(const std::string &truck_key) { + if (truck_key.empty()) + return std::nullopt; + return Read(Handle(false), truck_key); +} + +std::optional +CabinLayoutStore::LoadPreset(const std::string &truck_key) { + const CabinLayout *preset = FindCabinPreset(truck_key); + if (!preset) + return std::nullopt; + CabinLayout layout = *preset; + NormalizeCabinLayout(layout); + return layout; +} + +void CabinLayoutStore::Save(const std::string &truck_key, + const CabinLayout &layout) { + SPF_Config_Handle *h = truck_key.empty() ? nullptr : Handle(true); + if (!h) + return; + for (const CabinLayoutField &f : kFields) + config_->Cfg_SetFloat(h, Key(truck_key, f.name).c_str(), layout.*f.member); + config_->Cfg_SetBool(h, Key(truck_key, "has_passenger").c_str(), + layout.has_passenger); + config_->Cfg_SetBool(h, Key(truck_key, "has_bunk").c_str(), layout.has_bunk); + config_->Cfg_Save(h); +} + +void CabinLayoutStore::Forget(const std::string &truck_key) { + SPF_Config_Handle *h = truck_key.empty() ? nullptr : Handle(false); + if (!h) + return; + // Emptied rather than removed: Cfg_RemoveKey doesn't work on a custom + // context file (see ProfileManager.cpp). An empty layout reads as none. + config_->Cfg_SetJsonString(h, ("layouts." + truck_key).c_str(), "{}"); + config_->Cfg_Save(h); +} + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinLayouts.hpp b/src/effects/manual/cabin_walk/CabinLayouts.hpp new file mode 100644 index 0000000..0efab52 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinLayouts.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include "SPF_Config_API.h" +#include "SPF_TelemetryData.h" + +#include +#include +#include +#include + +namespace motioncab { + +// Where Cabin Walk can take the player in one truck's cabin. The game has +// no cabin geometry to read, so it's calibrated in-game per truck model +// (the Quick Settings' Advanced part) and falls back to a generic layout +// until then. +// +// Every position is in the interior camera's seat-position frame, as +// Cam_GetInteriorSeatPos reads it: meters, x right, y up, z backward. +// Angles are degrees, as the head rotation: yaw positive to the left, +// pitch positive up. A right-hand drive truck's interior camera mirrors +// the seat position left to right (seen in game: moving to -x there walks +// out of the driver's door), but not the yaw. So in that frame the cabin's +// centerline is at +x on every truck, angles keep their meaning, and a +// left-hand drive layout fits a right-hand drive truck of the same model +// as it is. +struct CabinLayout { + // The area the standing head can walk over. + float floor_x_min, floor_x_max, floor_z_min, floor_z_max; + float stand_y; // standing head height + float crouch_depth; // how far below it crouching takes the head + // The passenger seat: its position mirrors the player's own seat across + // the centerline (see CabinWalkEffect::SpotAt), following the seat as it's + // adjusted, moved by these offsets (meters: right, up, back), and its + // look. + float passenger_dx, passenger_dy, passenger_dz; + float passenger_yaw, passenger_pitch; + // Sitting on the bunk's front edge, halfway along the mattress. + float bunk_sit_x, bunk_sit_y, bunk_sit_z, bunk_sit_yaw, bunk_sit_pitch; + // False where the passenger seat can't be sat in (folded up, or none), or + // there's no bunk (a day cab): Cabin Walk never takes the player there. + // Not CabinLayoutFields (those are the sliders' floats); stored under + // their own names. + bool has_passenger = true; + bool has_bunk = true; +}; + +// The cabin's centerline, as an x in CabinLayout's frame: the driver's head +// (the frame's origin) sits this far from it, always at a positive x (see +// CabinLayout). SPF_CabinWalk's passenger seat at x = 0.95 puts it at about +// half that on the trucks it was tuned on. +inline constexpr float kTypicalCenterlineX = 0.475f; + +// The truck's centerline from its default head position, which telemetry +// gives relative to the cabin's pivot, on the centerline. A head closer +// than kMinHeadOffsetX to it is taken as missing data (a driver sits well +// off-center), and the typical centerline is used instead. +inline constexpr float kMinHeadOffsetX = 0.1f; +float CabinCenterlineX(const SPF_TruckConstants &constants); + +// A layout for a truck nobody calibrated, built around its centerline: the +// walkway runs down the middle, and the bunk sits across the back. Heights +// and depths are SPF_CabinWalk's defaults, since telemetry says nothing +// about them. +CabinLayout DefaultCabinLayout(float centerline_x = kTypicalCenterlineX); + +// One CabinLayout field: its name (storage key and "ui.cabin_walk.layout. +// " label), its slider's range and its unit. +struct CabinLayoutField { + const char *name; + float CabinLayout::*member; + float min, max; + bool angle; // degrees, else meters +}; + +std::span CabinLayoutFields(); + +// Swaps any min/max pair entered backwards, so the floor stays a valid box. +void NormalizeCabinLayout(CabinLayout &layout); + +// `layout` with its spots' looks mirrored: a left-hand drive layout's looks, +// 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); + +// The calibrated layouts, one per truck model: the player's own, in their +// own JSON file (SPF only gives a plugin one settings.json), created on the +// first save, and the presets compiled into the plugin (CabinPresets.hpp). +class CabinLayoutStore { +public: + CabinLayoutStore(SPF_Config_API *config, std::string path) + : config_(config), path_(std::move(path)) {} + + // Only the preset shipped with the plugin for `truck_key`, if any. + std::optional LoadPreset(const std::string &truck_key); + // Only the player's own layout for `truck_key`, if any. + std::optional LoadCustom(const std::string &truck_key); + // As the player's layout. + void Save(const std::string &truck_key, const CabinLayout &layout); + // Drops the player's layout, back to the preset if any: its entry is + // emptied (Cfg_RemoveKey doesn't work on a custom context file). + void Forget(const std::string &truck_key); + +private: + // The player's file, opened on first use; null while it doesn't exist, + // unless `create` (opening a custom context creates a missing file). + SPF_Config_Handle *Handle(bool create); + std::optional Read(SPF_Config_Handle *h, + const std::string &truck_key) const; + + SPF_Config_API *config_; + std::string path_; + SPF_Config_Handle *handle_ = nullptr; +}; + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinPresets.cpp b/src/effects/manual/cabin_walk/CabinPresets.cpp new file mode 100644 index 0000000..a1e949c --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinPresets.cpp @@ -0,0 +1,884 @@ +#include "CabinPresets.hpp" + +#include + +namespace motioncab { + +namespace { + +// The layouts shipped with the plugin, calibrated by hand in game in each +// model's smallest sleeper cabin (see CabinLayout for the frame), every +// field written out: a player's cabin_layouts.json entry has the same +// fields and values. +constexpr CabinPreset kPresets[] = { + // --- Euro Truck Simulator 2 --- + {"daf_vehicle_daf_2021", + {.floor_x_min = 0.47f, + .floor_x_max = 0.96f, + .floor_z_min = -0.32f, + .floor_z_max = 0.0f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.07f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.72f, + .bunk_sit_y = 0.145f, + .bunk_sit_z = 0.268f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"daf_vehicle_daf_xd", + {.floor_x_min = 0.37f, + .floor_x_max = 0.99f, + .floor_z_min = -0.255f, + .floor_z_max = -0.02f, + .stand_y = 0.309f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.12f, + .passenger_dz = -0.075f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.72f, + .bunk_sit_y = 0.055f, + .bunk_sit_z = 0.281f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"daf_vehicle_daf_xf", + {.floor_x_min = 0.37f, + .floor_x_max = 1.04f, + .floor_z_min = -0.32f, + .floor_z_max = 0.06f, + .stand_y = 0.295f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = -0.015f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.71f, + .bunk_sit_y = 0.064f, + .bunk_sit_z = 0.218f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"daf_vehicle_daf_xf_electric", + {.floor_x_min = 0.37f, + .floor_x_max = 1.01f, + .floor_z_min = -0.33f, + .floor_z_max = 0.0f, + .stand_y = 0.33f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.72f, + .bunk_sit_y = 0.12f, + .bunk_sit_z = 0.235f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = false, + .has_bunk = true}}, + {"daf_vehicle_daf_xf_euro6", + {.floor_x_min = 0.4f, + .floor_x_max = 1.17f, + .floor_z_min = -0.19f, + .floor_z_max = 0.17f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.78f, + .bunk_sit_y = 0.112f, + .bunk_sit_z = 0.42f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"iveco_vehicle_iveco_hiway", + {.floor_x_min = 0.53f, + .floor_x_max = 0.98f, + .floor_z_min = -0.31f, + .floor_z_max = 0.11f, + .stand_y = 0.33f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = -0.13f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.76f, + .bunk_sit_y = 0.18f, + .bunk_sit_z = 0.445f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"iveco_vehicle_iveco_stralis", + {.floor_x_min = 0.37f, + .floor_x_max = 1.11f, + .floor_z_min = -0.29f, + .floor_z_max = -0.01f, + .stand_y = 0.425f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = -0.11f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.75f, + .bunk_sit_y = 0.16f, + .bunk_sit_z = 0.32f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"iveco_vehicle_iveco_sway", + {.floor_x_min = 0.4f, + .floor_x_max = 1.11f, + .floor_z_min = -0.23f, + .floor_z_max = 0.07f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.08f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.73f, + .bunk_sit_y = 0.03f, + .bunk_sit_z = 0.36f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"man_vehicle_man_tgx", + {.floor_x_min = 0.38f, + .floor_x_max = 1.21f, + .floor_z_min = -0.41f, + .floor_z_max = 0.06f, + .stand_y = 0.244f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.06f, + .passenger_dz = -0.033f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.81f, + .bunk_sit_y = -0.039f, + .bunk_sit_z = 0.236f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"man_vehicle_man_tgx_2020", + {.floor_x_min = 0.37f, + .floor_x_max = 1.04f, + .floor_z_min = -0.33f, + .floor_z_max = 0.04f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.08f, + .passenger_dz = -0.11f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.74f, + .bunk_sit_y = 0.07f, + .bunk_sit_z = 0.295f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"man_vehicle_man_tgx_euro6", + {.floor_x_min = 0.5f, + .floor_x_max = 1.05f, + .floor_z_min = -0.28f, + .floor_z_max = -0.01f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.08f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.74f, + .bunk_sit_y = 0.04f, + .bunk_sit_z = 0.321f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"mercedes_vehicle_mercedes_actros", + {.floor_x_min = 0.44f, + .floor_x_max = 1.05f, + .floor_z_min = -0.28f, + .floor_z_max = 0.09f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.04f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.78f, + .bunk_sit_y = 0.005f, + .bunk_sit_z = 0.415f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"mercedes_vehicle_mercedes_actros2014", + {.floor_x_min = 0.47f, + .floor_x_max = 1.03f, + .floor_z_min = -0.21f, + .floor_z_max = 0.133f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.71f, + .bunk_sit_y = 0.02f, + .bunk_sit_z = 0.5f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"renault_vehicle_renault_etech_t", + {.floor_x_min = 0.37f, + .floor_x_max = 1.0f, + .floor_z_min = -0.237f, + .floor_z_max = 0.09f, + .stand_y = 0.38f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.729f, + .bunk_sit_y = 0.055f, + .bunk_sit_z = 0.27f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"renault_vehicle_renault_magnum", + {.floor_x_min = 0.51f, + .floor_x_max = 0.74f, + .floor_z_min = -0.25f, + .floor_z_max = 0.204f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = -0.09f, + .passenger_dy = 0.0f, + .passenger_dz = -0.248f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.666f, + .bunk_sit_y = -0.149f, + .bunk_sit_z = 0.717f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = false}}, + {"renault_vehicle_renault_premium", + {.floor_x_min = 0.37f, + .floor_x_max = 0.99f, + .floor_z_min = -0.24f, + .floor_z_max = -0.03f, + .stand_y = 0.405f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = -0.135f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.68f, + .bunk_sit_y = 0.095f, + .bunk_sit_z = 0.19f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"renault_vehicle_renault_t", + {.floor_x_min = 0.43f, + .floor_x_max = 0.97f, + .floor_z_min = -0.26f, + .floor_z_max = -0.02f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.7f, + .bunk_sit_y = -0.13f, + .bunk_sit_z = 0.255f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"scania_vehicle_scania_r", + {.floor_x_min = 0.37f, + .floor_x_max = 0.99f, + .floor_z_min = -0.26f, + .floor_z_max = 0.18f, + .stand_y = 0.368f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.05f, + .passenger_dz = -0.035f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.65f, + .bunk_sit_y = 0.05f, + .bunk_sit_z = 0.425f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"scania_vehicle_scania_r_2016", + {.floor_x_min = 0.37f, + .floor_x_max = 1.0f, + .floor_z_min = -0.32f, + .floor_z_max = 0.04f, + .stand_y = 0.43f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.07f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.68f, + .bunk_sit_y = 0.03f, + .bunk_sit_z = 0.26f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"scania_vehicle_scania_s_2016", + {.floor_x_min = 0.41f, + .floor_x_max = 0.96f, + .floor_z_min = -0.32f, + .floor_z_max = 0.04f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.07f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.68f, + .bunk_sit_y = 0.03f, + .bunk_sit_z = 0.296f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"scania_vehicle_scania_s_2024e", + {.floor_x_min = 0.41f, + .floor_x_max = 0.96f, + .floor_z_min = -0.32f, + .floor_z_max = 0.04f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.07f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.68f, + .bunk_sit_y = 0.07f, + .bunk_sit_z = 0.28f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"scania_vehicle_scania_streamline", + {.floor_x_min = 0.37f, + .floor_x_max = 0.99f, + .floor_z_min = -0.26f, + .floor_z_max = 0.18f, + .stand_y = 0.368f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.05f, + .passenger_dz = -0.035f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.65f, + .bunk_sit_y = 0.05f, + .bunk_sit_z = 0.425f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_fh16", + {.floor_x_min = 0.37f, + .floor_x_max = 1.07f, + .floor_z_min = -0.15f, + .floor_z_max = 0.12f, + .stand_y = 0.381f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = -0.08f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.69f, + .bunk_sit_y = 0.08f, + .bunk_sit_z = 0.4f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_fh16_2012", + {.floor_x_min = 0.38f, + .floor_x_max = 1.0f, + .floor_z_min = -0.32f, + .floor_z_max = 0.0f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.145f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.714f, + .bunk_sit_y = 0.01f, + .bunk_sit_z = 0.241f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_fh_2021", + {.floor_x_min = 0.38f, + .floor_x_max = 1.0f, + .floor_z_min = -0.28f, + .floor_z_max = 0.0f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.085f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.698f, + .bunk_sit_y = 0.0f, + .bunk_sit_z = 0.241f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_fh_2024", + {.floor_x_min = 0.38f, + .floor_x_max = 1.0f, + .floor_z_min = -0.28f, + .floor_z_max = 0.0f, + .stand_y = 0.28f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.08f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.73f, + .bunk_sit_y = 0.069f, + .bunk_sit_z = 0.223f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + // --- American Truck Simulator --- + {"freightliner_vehicle_freightliner_cascadia2019", + {.floor_x_min = 0.401f, + .floor_x_max = 0.801f, + .floor_z_min = -0.284f, + .floor_z_max = 0.208f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.02f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.61f, + .bunk_sit_y = 0.055f, + .bunk_sit_z = 0.47f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"freightliner_vehicle_freightliner_cascadia2024", + {.floor_x_min = 0.401f, + .floor_x_max = 0.801f, + .floor_z_min = -0.284f, + .floor_z_max = 0.208f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.02f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.61f, + .bunk_sit_y = 0.055f, + .bunk_sit_z = 0.47f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"intnational_vehicle_intnational_9900i", + {.floor_x_min = 0.277f, + .floor_x_max = 0.677f, + .floor_z_min = -0.5f, + .floor_z_max = 0.123f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.13f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.475f, + .bunk_sit_y = 0.034f, + .bunk_sit_z = 0.423f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"intnational_vehicle_intnational_lonestar", + {.floor_x_min = 0.367f, + .floor_x_max = 0.767f, + .floor_z_min = -0.15f, + .floor_z_max = 0.4f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.05f, + .passenger_dz = -0.1f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.567f, + .bunk_sit_y = -0.03f, + .bunk_sit_z = 0.62f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"intnational_vehicle_intnational_lt", + {.floor_x_min = 0.367f, + .floor_x_max = 0.767f, + .floor_z_min = -0.15f, + .floor_z_max = 0.4f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.05f, + .passenger_dz = -0.1f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.567f, + .bunk_sit_y = -0.03f, + .bunk_sit_z = 0.62f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"kenworth_vehicle_kenworth_t680", + {.floor_x_min = 0.306f, + .floor_x_max = 0.692f, + .floor_z_min = -0.554f, + .floor_z_max = 0.001f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.13f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.51f, + .bunk_sit_y = 0.03f, + .bunk_sit_z = 0.31f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"kenworth_vehicle_kenworth_t680_2022", + {.floor_x_min = 0.31f, + .floor_x_max = 0.71f, + .floor_z_min = -0.395f, + .floor_z_max = 0.413f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.06f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.51f, + .bunk_sit_y = 0.095f, + .bunk_sit_z = 0.605f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"kenworth_vehicle_kenworth_w900", + {.floor_x_min = 0.223f, + .floor_x_max = 0.623f, + .floor_z_min = -0.5f, + .floor_z_max = 0.681f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.03f, + .passenger_dy = 0.0f, + .passenger_dz = -0.045f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.423f, + .bunk_sit_y = -0.06f, + .bunk_sit_z = 0.87f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"mack_vehicle_mack_anthem", + {.floor_x_min = 0.354f, + .floor_x_max = 0.754f, + .floor_z_min = -0.4f, + .floor_z_max = 0.23f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.1f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.55f, + .bunk_sit_y = 0.04f, + .bunk_sit_z = 0.45f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"mack_vehicle_mack_pinnacle", + {.floor_x_min = 0.354f, + .floor_x_max = 0.754f, + .floor_z_min = -0.4f, + .floor_z_max = 0.23f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.1f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.55f, + .bunk_sit_y = 0.04f, + .bunk_sit_z = 0.45f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"peterbilt_vehicle_peterbilt_389", + {.floor_x_min = 0.297f, + .floor_x_max = 0.579f, + .floor_z_min = -0.314f, + .floor_z_max = 0.926f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = 0.0f, + .passenger_dz = 0.0f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.405f, + .bunk_sit_y = 0.11f, + .bunk_sit_z = 1.195f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"peterbilt_vehicle_peterbilt_579", + {.floor_x_min = 0.337f, + .floor_x_max = 0.737f, + .floor_z_min = -0.5f, + .floor_z_max = 0.838f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.02f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.537f, + .bunk_sit_y = 0.0f, + .bunk_sit_z = 1.067f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_vnl", + {.floor_x_min = 0.42f, + .floor_x_max = 0.77f, + .floor_z_min = -0.4f, + .floor_z_max = 0.816f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.62f, + .bunk_sit_y = -0.086f, + .bunk_sit_z = 1.069f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_vnl2018", + {.floor_x_min = 0.37f, + .floor_x_max = 0.85f, + .floor_z_min = -0.32f, + .floor_z_max = 0.62f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.61f, + .bunk_sit_y = 0.049f, + .bunk_sit_z = 0.881f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_vnl2025", + {.floor_x_min = 0.351f, + .floor_x_max = 0.751f, + .floor_z_min = -0.311f, + .floor_z_max = 0.18f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.02f, + .passenger_dz = -0.02f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.551f, + .bunk_sit_y = 0.0f, + .bunk_sit_z = 0.393f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"volvo_vehicle_volvo_vnr_e", + {.floor_x_min = 0.37f, + .floor_x_max = 0.85f, + .floor_z_min = -0.32f, + .floor_z_max = 0.04f, + .stand_y = 0.2f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.03f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.475f, + .bunk_sit_y = 0.0f, + .bunk_sit_z = 0.8f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = false}}, + {"westernstar_vehicle_westernstar_49x", + {.floor_x_min = 0.44f, + .floor_x_max = 0.73f, + .floor_z_min = -0.28f, + .floor_z_max = 0.25f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.03f, + .passenger_dz = -0.05f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.6f, + .bunk_sit_y = 0.055f, + .bunk_sit_z = 0.509f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"westernstar_vehicle_westernstar_5700xe", + {.floor_x_min = 0.44f, + .floor_x_max = 0.71f, + .floor_z_min = -0.12f, + .floor_z_max = 0.533f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.02f, + .passenger_dz = -0.02f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 0.573f, + .bunk_sit_y = 0.04f, + .bunk_sit_z = 0.8f, + .bunk_sit_yaw = 0.0f, + .bunk_sit_pitch = -10.0f, + .has_passenger = true, + .has_bunk = true}}, + {"westernstar_vehicle_westernstar_57x", + {.floor_x_min = 0.44f, + .floor_x_max = 0.73f, + .floor_z_min = -0.28f, + .floor_z_max = 0.75f, + .stand_y = 0.23f, + .crouch_depth = 0.45f, + .passenger_dx = 0.0f, + .passenger_dy = -0.04f, + .passenger_dz = -0.05f, + .passenger_yaw = 0.0f, + .passenger_pitch = 0.0f, + .bunk_sit_x = 1.227f, + .bunk_sit_y = -0.03f, + .bunk_sit_z = 1.328f, + .bunk_sit_yaw = 90.0f, + .bunk_sit_pitch = -5.413f, + .has_passenger = true, + .has_bunk = true}}, +}; + +} // namespace + +std::span CabinPresets() { return kPresets; } + +const CabinLayout *FindCabinPreset(std::string_view key) { + const auto it = std::find_if( + std::begin(kPresets), std::end(kPresets), + [&](const CabinPreset &preset) { return preset.key == key; }); + return it == std::end(kPresets) ? nullptr : &it->layout; +} + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinPresets.hpp b/src/effects/manual/cabin_walk/CabinPresets.hpp new file mode 100644 index 0000000..8a02bb6 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinPresets.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "CabinLayouts.hpp" + +#include +#include + +namespace motioncab { + +// A layout shipped with the plugin, under its truck model's key +// (TruckLayoutKey). +struct CabinPreset { + std::string_view key; + CabinLayout layout; +}; + +// Every shipped layout (ETS2's and ATS's: no key is shared between them). +std::span CabinPresets(); + +// The shipped layout for `key`, null if there's none. +const CabinLayout *FindCabinPreset(std::string_view key); + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinWalkEffect.cpp b/src/effects/manual/cabin_walk/CabinWalkEffect.cpp new file mode 100644 index 0000000..6b83748 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinWalkEffect.cpp @@ -0,0 +1,795 @@ +#include "CabinWalkEffect.hpp" + +#include "core/Keybinds.hpp" +#include "effects/TelemetryUtil.hpp" +#include "math/Units.hpp" + +#include +#include +#include +#include + +namespace motioncab { + +using math::kDegToRad; +using math::kPi; + +namespace { + +// Walking speed (m/s), slower backward and sideways, and how long it takes +// to get up to speed and to stop (stopping is quicker). +constexpr float kWalkSpeed = 1.0f; +constexpr float kBackwardShare = 0.6f; +constexpr float kSidewaysShare = 0.75f; +constexpr float kAccelerationSeconds = 0.15f; +constexpr float kDecelerationSeconds = 0.1f; +// How deep the head dips each step (meters). +constexpr float kHeadBob = 0.012f; +// The footsteps' volume (0..1) at walking speed. +constexpr float kFootstepVolume = 0.8f; +// Standing up needs the truck at rest; above kMaxAwayKmh the player is +// sent back to the wheel (a parked truck still creeps a little on a slope). +constexpr float kMaxStandKmh = 1.0f; +constexpr float kMaxAwayKmh = 3.0f; +// Holding stand/sit this long goes back to the wheel instead of the tap's +// "sit at the nearest spot". +constexpr float kLongPressSeconds = 0.6f; +// Standing up or sitting down takes kTransitionSeconds for kTransitionMeters, +// scaled by the actual distance within the bounds below. +constexpr float kTransitionSeconds = 1.2f; +constexpr float kTransitionMeters = 0.7f; +constexpr float kTransitionScaleMin = 0.7f; +constexpr float kTransitionScaleMax = 1.6f; +// Hurried back to the wheel (parking brake released, truck rolling). +constexpr float kHurrySeconds = 0.8f; +// Walking by itself to a spot: close enough to sit down from, and how +// soon it slows down before it (speed = distance * gain). +constexpr float kArriveMeters = 0.04f; +constexpr float kArriveGain = 4.0f; +// Crouching slows walking down by this fraction. +constexpr float kCrouchSlowdown = 0.5f; +// Head height changes (crouching). +constexpr float kHeightTime = 0.3f; +// Step bob: one dip per step of kStepMeters, a sideways sway of this +// fraction of the dip, and a slight roll along with the sway. +constexpr float kStepMeters = 0.45f; +constexpr float kBobSwayRatio = 0.6f; +constexpr float kBobRollDegPerMeter = 40.0f; +// How long the head's rotation is held after the camera's re-activation, +// which recenters it a frame or so later. +constexpr float kHoldRotationSeconds = 0.25f; +// Within this distance of a seat (on the floor plane), a tap sits there +// whatever the look. +constexpr float kOverSpotMeters = 0.08f; +// No footstep sound below this share of walking speed (shuffling in place). +constexpr float kMinStepSoundAmount = 0.3f; +constexpr float kBobTime = 0.2f; +// Standing, the body stays upright while the parked truck leans (half on a +// kerb, on a slope): the head tilts against the truck's tilt, turning +// along with standing up and sitting down, within a sane bound. +constexpr float kMaxUprightDeg = 15.0f; +// The camera's roll turns the head the other way from the truck's roll +// (seen in game: +1 leaned the head further with the truck). +constexpr float kUprightRollSign = -1.0f; +// Turning all the way around while up, and looking down at the floor. The +// yaw is wrapped back into +-180 every frame (see Update), so this is just +// headroom the mouse never reaches: 90 degrees past a half-turn in a frame. +constexpr float kAwayYawLimitDeg = 270.0f; +constexpr float kAwayDownLimitDeg = -80.0f; + +// Standing up leans the head forward (over the feet) and a bit down before +// rising; the rise then ends straight up. +constexpr float kRiseLeanMeters = 0.12f; +constexpr float kRiseDipMeters = 0.04f; +constexpr float kRiseEndLiftMeters = 0.10f; +// Sitting down steps over at standing height first, then lowers from a bit +// in front of and above the seat, settling back against the backrest. +constexpr float kSitStepShare = 0.4f; +constexpr float kSitLeanMeters = 0.12f; +constexpr float kSitAboveMeters = 0.10f; +// Moving between seated spots arcs over, more for a longer move. +constexpr float kArcMeters = 0.05f; +constexpr float kArcFullMeters = 0.6f; +// Looking down during the move, and leaning into it on the way to the +// driver's or the passenger seat (degrees, at mid-move). +constexpr float kRiseLookDownDeg = 8.0f; +constexpr float kSitLookDownDeg = 10.0f; +constexpr float kDirectLookDownDeg = 4.0f; +constexpr float kLeanRollDeg = 3.0f; +// The view turns to a seat's look a bit before the head gets there. +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); } + +} // namespace + +CabinWalkEffect::CabinWalkEffect(SPF_Config_API *config_api, + SPF_Config_Handle *config_handle, + SPF_KeyBinds_API *keybinds_api, + SPF_KeyBinds_Handle *keybinds_handle, + SPF_UI_API *ui_api, CabinLayoutStore *layouts) + : config_(config_api, config_handle, "manual", "cabin_walk"), + keybinds_api_(keybinds_api), keybinds_handle_(keybinds_handle), + ui_api_(ui_api), layouts_(layouts) { + height_.SetTimeConstant(kHeightTime); + bob_amount_.SetTimeConstant(kBobTime); +} + +void CabinWalkEffect::LoadConfig() { + if (!config_.IsAvailable()) + return; + enabled_ = config_.Bool("enabled", enabled_); + require_parking_brake_ = + config_.Bool("require_parking_brake", require_parking_brake_); +} + +HeadOffset CabinWalkEffect::Update(float dt, const SPF_TruckData &truck, + SPF_Camera_API *camera, + const CameraRig::Pose &base) { + // First frame back from another view (see OnLeftInterior). + const bool back_in_cabin = std::exchange(left_interior_, false); + base_ = {base.seat_x, base.seat_y, base.seat_z}; + base_yaw_ = base.yaw_rad; + base_pitch_ = base.pitch_rad; + base_rotation_.reset(); + if (hold_rotation_time_ > 0.0f) { + hold_rotation_time_ -= dt; + std::tie(base_yaw_, base_pitch_) = held_rotation_; + base_rotation_ = held_rotation_; + if (hold_rotation_time_ <= 0.0f) + camera_override_.EndRefresh(camera); + } + + // Turning around freely: past a half-turn, the same look a full turn + // back, before the mouse reaches the widened limit. (Not in a seat that + // keeps the truck's own limits, which may go past 180 on purpose.) + const bool full_turn = + !AtWheel() && !(state_ == State::kSeated && spot_ == Spot::kPassenger); + if (full_turn && std::fabs(base_yaw_) > kPi) { + base_yaw_ = WrapYaw(base_yaw_); + base_rotation_ = {base_yaw_, base_pitch_}; + } + + // Anything that makes being up unsafe (or unwanted) brings the player + // back, unless already on the way to the wheel. + const bool heading_to_wheel = state_ == State::kTransition && + after_ == State::kSeated && + after_spot_ == Spot::kDriver; + if (!AtWheel() && !heading_to_wheel) { + const bool unsafe = telemetry::SpeedKmh(truck) > kMaxAwayKmh || + (require_parking_brake_ && !truck.parking_brake); + if (unsafe && back_in_cabin) { + // It became unsafe while out of the interior view (driving from + // another camera): no walk back to the wheel in the middle of it. + notice_ = Notice::kBackToWheel; + camera_override_.Restore(camera); + SnapToWheel(); + } else if (unsafe || !enabled_) { + if (enabled_) + notice_ = Notice::kBackToWheel; + GoToWheel(unsafe ? kHurrySeconds : TransitionDuration(base_)); + } + } + + if (enabled_) { + HandleStandSitKey(dt, truck, camera); + if (go_to_request_) + HandleGoTo(*std::exchange(go_to_request_, std::nullopt), truck, camera); + } + + switch (state_) { + case State::kSeated: + if (spot_ == Spot::kDriver) + pos_ = base_; + else + FollowSeatedSpot(camera); + break; + case State::kTransition: + UpdateTransition(dt, camera); + break; + case State::kWalking: + UpdateWalking(dt); + break; + } + + if (camera_override_.refreshes() != seen_refreshes_) { + seen_refreshes_ = camera_override_.refreshes(); + held_rotation_ = base_rotation_.value_or(std::pair{base_yaw_, base_pitch_}); + hold_rotation_time_ = kHoldRotationSeconds; + } + + camera_override_.KeepNearPlane(camera); + SetMouseBlocked(state_ == State::kTransition && turn_); + SetWalkKeysBlocked(!AtWheel()); + + // Step bob, fading in and out with the walking speed. + const float speed = state_ == State::kWalking + ? std::hypot(vel_x_.value(), vel_z_.value()) + : 0.0f; + const float amount = + std::clamp(bob_amount_.Update(speed / kWalkSpeed, dt), 0.0f, 1.0f); + const float phase_before = bob_phase_; + bob_phase_ = math::WrapPhase(bob_phase_ + speed * dt / kStepMeters * kPi); + // A footstep where the head dips lowest, a quarter-turn into each step: + // where the phase's cosine changes sign. + if (amount > kMinStepSoundAmount && + (std::cos(phase_before) > 0.0f) != (std::cos(bob_phase_) > 0.0f)) + footsteps_.push_back(kFootstepVolume * amount); + const float sway = kHeadBob * kBobSwayRatio * amount * std::sin(bob_phase_); + const float dip = + kHeadBob * amount * 0.5f * (1.0f - std::cos(2.0f * bob_phase_)); + + HeadOffset offset; + offset.pos_x = pos_.x - base_.x + sway * CameraX() * std::cos(base_yaw_); + offset.pos_y = pos_.y - base_.y - dip; + offset.pos_z = pos_.z - base_.z - sway * std::sin(base_yaw_); + offset.pitch = motion_pitch_deg_; + offset.roll = kBobRollDegPerMeter * kHeadBob * amount * std::sin(bob_phase_) + + motion_roll_deg_; + + // Upright against the truck's tilt, seen from where the head looks (yaw + // positive to the left): the truck's roll is a roll looking ahead and a + // pitch looking aside, its pitch the other way around. + // Upright on the feet, with the cabin in a seat: it changes along with + // the move between the two (standing up, sitting down), not after it. + const float on_feet = state_ == State::kWalking ? 1.0f : 0.0f; + const float upright = + state_ == State::kTransition + ? Lerp(upright_from_, after_ == State::kWalking ? 1.0f : 0.0f, + SmootherStep(progress_)) + : on_feet; + upright_now_ = upright; + if (upright > 1e-3f) { + const auto tilt_deg = [](float turns) { + return std::clamp(turns * 360.0f, -kMaxUprightDeg, kMaxUprightDeg); + }; + const float pitch = tilt_deg(truck.world_placement.orientation.pitch); + const float roll = tilt_deg(truck.world_placement.orientation.roll); + const float c = std::cos(base_yaw_), s = std::sin(base_yaw_); + offset.pitch += upright * (-pitch * c + roll * s); + offset.roll += upright * kUprightRollSign * (roll * c + pitch * s); + } + return offset; +} + +// --- Spots --- + +CabinWalkEffect::SpotPose CabinWalkEffect::SpotAt(Spot spot) const { + const CabinLayout &l = layout_; + auto pose = [](float x, float y, float z, float yaw_deg, float pitch_deg) { + return SpotPose{{x, y, z}, yaw_deg * kDegToRad, pitch_deg * kDegToRad}; + }; + switch (spot) { + case Spot::kPassenger: + // The player's own seat mirrored across the centerline: sat at the same + // height, depth and distance from the center as at the wheel. + return pose(2.0f * centerline_x_ - base_.x + l.passenger_dx, + base_.y + l.passenger_dy, base_.z + l.passenger_dz, + l.passenger_yaw, l.passenger_pitch); + case Spot::kBunkSit: + return pose(l.bunk_sit_x, l.bunk_sit_y, l.bunk_sit_z, l.bunk_sit_yaw, + l.bunk_sit_pitch); + case Spot::kDriver: + break; + } + // The player's own seat, looking where the truck's recenter does. + float yaw_deg = 0.0f, pitch_deg = 0.0f; + if (camera_override_.engaged()) { + yaw_deg = camera_override_.original_default_yaw(); + pitch_deg = camera_override_.original_default_pitch(); + } + return {base_, yaw_deg * kDegToRad, pitch_deg * kDegToRad}; +} + +CabinWalkEffect::Vec3 CabinWalkEffect::Approach(Spot spot) const { + const Vec3 p = SpotAt(spot).pos; + return {std::clamp(p.x, layout_.floor_x_min, layout_.floor_x_max), + layout_.stand_y, + std::clamp(p.z, layout_.floor_z_min, layout_.floor_z_max)}; +} + +CabinWalkEffect::Spot CabinWalkEffect::LookedAtSpot() const { + // The seat whose direction is closest to the look, on the floor plane: + // the seats are too close together in most cabins to go by distance. + const float look_x = -CameraX() * std::sin(base_yaw_); + const float look_z = -std::cos(base_yaw_); + Spot best = Spot::kDriver; + float best_cos = -2.0f; + for (Spot spot : {Spot::kDriver, Spot::kPassenger, Spot::kBunkSit}) { + if (!CanSitAt(spot)) + continue; + const Vec3 p = SpotAt(spot).pos; + const float dx = p.x - pos_.x, dz = p.z - pos_.z; + const float distance = std::hypot(dx, dz); + // Standing on top of it, where the look points doesn't tell. + const float cos = distance < kOverSpotMeters + ? 1.0f + : (dx * look_x + dz * look_z) / distance; + if (cos > best_cos) { + best_cos = cos; + best = spot; + } + } + return best; +} + +// --- Keys --- + +void CabinWalkEffect::HandleStandSitKey(float dt, const SPF_TruckData &truck, + SPF_Camera_API *camera) { + if (!keybinds_api_ || !keybinds_handle_) + return; + const bool held = + keybinds::IsHeld(keybinds_api_, keybinds_handle_, keybinds::kStandSit); + bool tap = false, long_press = false; + if (held) { + stand_sit_held_time_ = stand_sit_held_ ? stand_sit_held_time_ + dt : 0.0f; + if (!stand_sit_long_done_ && stand_sit_held_time_ >= kLongPressSeconds) { + long_press = true; + stand_sit_long_done_ = true; + } + } else { + tap = stand_sit_held_ && !stand_sit_long_done_; + stand_sit_long_done_ = false; + } + stand_sit_held_ = held; + + switch (state_) { + case State::kSeated: + if (spot_ == Spot::kDriver) { + if (tap || long_press) + TryStandUp(truck, camera); + } else if (long_press) { + StandUp(); + walk_to_ = Spot::kDriver; + } else if (tap) { + StandUp(); + } + break; + case State::kWalking: + if (long_press) + walk_to_ = Spot::kDriver; + else if (tap) + walk_to_ = LookedAtSpot(); + break; + case State::kTransition: + break; + } +} + +void CabinWalkEffect::TryStandUp(const SPF_TruckData &truck, + SPF_Camera_API *camera) { + if (telemetry::SpeedKmh(truck) > kMaxStandKmh) { + notice_ = Notice::kNeedsStop; + return; + } + if (require_parking_brake_ && !truck.parking_brake) { + notice_ = Notice::kNeedsParkingBrake; + return; + } + if (!camera_override_.Engage(camera)) { + // Camera not resolved yet: try again on the next press. + return; + } + // Up from where the head really was: leaning toward a mirror, the truck's + // own head offset for that look, now gone. + const std::array &lean = camera_override_.engage_head_offset(); + pos_ = {base_.x + lean[0], base_.y + lean[1], base_.z + lean[2]}; + StandUp(); +} + +void CabinWalkEffect::StandUp() { + const Vec3 to = Approach(spot_); + StartTransition(to, Path::kRise, TransitionDuration(to), State::kWalking, + Spot::kDriver, false); +} + +void CabinWalkEffect::SitAt(Spot spot) { + const Vec3 to = SpotAt(spot).pos; + StartTransition(to, Path::kSit, TransitionDuration(to), State::kSeated, spot, + true); +} + +void CabinWalkEffect::GoToWheel(float duration) { + walk_to_.reset(); + // From standing, it's sitting down; from another seat, moving over. + const bool standing = state_ == State::kWalking; + StartTransition(base_, standing ? Path::kSit : Path::kDirect, duration, + State::kSeated, Spot::kDriver, true); +} + +// --- Transitions --- + +CabinWalkEffect::Vec3 CabinWalkEffect::Facing(Spot spot) const { + // Yaw 0 looks toward -z, positive yaw turns left. + const float yaw = SpotAt(spot).yaw_rad; + return Vec3{-CameraX() * std::sin(yaw), 0.0f, -std::cos(yaw)}; +} + +void CabinWalkEffect::StartTransition(const Vec3 &to, Path path, float duration, + State after, Spot after_spot, bool turn) { + from_ = pos_; + to_ = to; + upright_from_ = upright_now_; + path_ = path; + progress_ = 0.0f; + duration_ = std::max(duration, 0.05f); + after_ = after; + after_spot_ = after_spot; + turn_ = turn; + yaw_from_ = WrapYaw(base_yaw_); + pitch_from_ = base_pitch_; + + auto facing = [&](Spot spot) { return Facing(spot); }; + const float dx = to.x - from_.x, dy = to.y - from_.y, dz = to.z - from_.z; + const float horizontal = std::hypot(dx, dz); + switch (path) { + case Path::kRise: { + const Vec3 f = facing(spot_); + ctrl1_ = {from_.x + f.x * kRiseLeanMeters, from_.y - kRiseDipMeters, + from_.z + f.z * kRiseLeanMeters}; + ctrl2_offset_ = {0.0f, -kRiseEndLiftMeters, 0.0f}; + break; + } + case Path::kSit: { + const Vec3 f = facing(after_spot); + ctrl1_ = {from_.x + dx * kSitStepShare, from_.y, + from_.z + dz * kSitStepShare}; + ctrl2_offset_ = {f.x * kSitLeanMeters, kSitAboveMeters, + f.z * kSitLeanMeters}; + break; + } + case Path::kDirect: { + // Arcs over a sideways move, barely over a mostly vertical one. + const float arc = kArcMeters * Clamp01(horizontal / kArcFullMeters) * + horizontal / (horizontal + std::fabs(dy) + 1e-3f); + ctrl1_ = {from_.x + dx / 3.0f, from_.y + dy / 3.0f + arc, + from_.z + dz / 3.0f}; + ctrl2_offset_ = {-dx / 3.0f, -dy / 3.0f + arc, -dz / 3.0f}; + break; + } + } + + // Sideways share of the move as seen from where the player looks, for + // leaning into it on the way to a front seat (not the bunk). + const bool front_seat = + after == State::kSeated && after_spot != Spot::kBunkSit; + const float right = + CameraX() * dx * std::cos(base_yaw_) - dz * std::sin(base_yaw_); + lean_side_ = front_seat + ? std::clamp(right / std::max(horizontal, 0.2f), -1.0f, 1.0f) + : 0.0f; + + state_ = State::kTransition; +} + +float CabinWalkEffect::TransitionDuration(const Vec3 &from, + const Vec3 &to) const { + const float dx = to.x - from.x, dy = to.y - from.y, dz = to.z - from.z; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + return kTransitionSeconds * std::clamp(distance / kTransitionMeters, + kTransitionScaleMin, + kTransitionScaleMax); +} + +CabinWalkEffect::Vec3 CabinWalkEffect::Bezier(const Vec3 &p0, const Vec3 &p1, + const Vec3 &p2, const Vec3 &p3, + float u) { + const float a = (1.0f - u) * (1.0f - u) * (1.0f - u); + const float b = 3.0f * (1.0f - u) * (1.0f - u) * u; + const float c = 3.0f * (1.0f - u) * u * u; + const float d = u * u * u; + return {a * p0.x + b * p1.x + c * p2.x + d * p3.x, + a * p0.y + b * p1.y + c * p2.y + d * p3.y, + a * p0.z + b * p1.z + c * p2.z + d * p3.z}; +} + +void CabinWalkEffect::UpdateTransition(float dt, SPF_Camera_API *camera) { + progress_ = std::min(1.0f, progress_ + dt / duration_); + const float t = progress_; + // The seat follows the player's own seat, should it change meanwhile. + if (after_ == State::kSeated) + to_ = SpotAt(after_spot_).pos; + + // Along the Bezier path, eased so the body starts and stops smoothly. + const Vec3 ctrl2{to_.x + ctrl2_offset_.x, to_.y + ctrl2_offset_.y, + to_.z + ctrl2_offset_.z}; + pos_ = Bezier(from_, ctrl1_, ctrl2, to_, SmootherStep(t)); + + // The body's own motion on top: looking down at where it goes, leaning + // into a sideways move. + const float look_down = path_ == Path::kRise ? kRiseLookDownDeg + : path_ == Path::kSit ? kSitLookDownDeg + : kDirectLookDownDeg; + motion_pitch_deg_ = -look_down * Bell(t); + motion_roll_deg_ = kLeanRollDeg * lean_side_ * Bell(t); + + if (turn_) { + const SpotPose target = SpotAt(after_spot_); + const float r = SmootherStep(t / kTurnShare); + base_rotation_ = {Lerp(yaw_from_, WrapYaw(target.yaw_rad), r), + Lerp(pitch_from_, target.pitch_rad, r)}; + } + + if (progress_ >= 1.0f) + OnArrived(camera); +} + +void CabinWalkEffect::OnArrived(SPF_Camera_API *camera) { + state_ = after_; + pos_ = to_; + motion_pitch_deg_ = 0.0f; + motion_roll_deg_ = 0.0f; + vel_x_.Reset(); + vel_z_.Reset(); + height_.Reset(pos_.y); + const InteriorCameraOverride::Limits &own = + camera_override_.original_limits(); + const InteriorCameraOverride::Limits away{kAwayYawLimitDeg, -kAwayYawLimitDeg, + own.up, kAwayDownLimitDeg}; + if (state_ == State::kWalking) { + camera_override_.SetLimits(camera, away); + camera_override_.SetDefaults(camera, + camera_override_.original_default_yaw(), + camera_override_.original_default_pitch()); + return; + } + + spot_ = after_spot_; + if (spot_ == Spot::kDriver) { + camera_override_.Restore(camera); + return; + } + // The passenger seat mirrors the driver's view range; the bunk allows + // looking all around, as standing does. The recenter key looks where the + // spot does. + if (spot_ == Spot::kPassenger) + camera_override_.SetLimits(camera, + {-own.right, -own.left, own.up, own.down}); + else + camera_override_.SetLimits(camera, away); + const SpotPose pose = SpotAt(spot_); + seated_yaw_deg_ = pose.yaw_rad / kDegToRad; + seated_pitch_deg_ = pose.pitch_rad / kDegToRad; + camera_override_.SetDefaults(camera, seated_yaw_deg_, seated_pitch_deg_); +} + +void CabinWalkEffect::FollowSeatedSpot(SPF_Camera_API *camera) { + const SpotPose pose = SpotAt(spot_); + pos_ = pose.pos; + const float yaw_deg = pose.yaw_rad / kDegToRad; + const float pitch_deg = pose.pitch_rad / kDegToRad; + if (yaw_deg == seated_yaw_deg_ && pitch_deg == seated_pitch_deg_) + return; + // The spot's look was edited: look that way, and recenter to it. + seated_yaw_deg_ = yaw_deg; + seated_pitch_deg_ = pitch_deg; + base_rotation_ = {WrapYaw(pose.yaw_rad), pose.pitch_rad}; + camera_override_.SetDefaults(camera, yaw_deg, pitch_deg); +} + +// --- Walking --- + +float CabinWalkEffect::ClampToFloor(float value, float min, float max, + float &velocity, + math::SpringDamper1D &spring) const { + const float clamped = std::clamp(value, min, max); + if (clamped != value) { + // Against a wall: slide along it instead of pushing into it. + spring.Reset(); + velocity = 0.0f; + } + return clamped; +} + +void CabinWalkEffect::UpdateWalking(float dt) { + const auto amount = [&](const keybinds::Action &action) { + return keybinds::Amount(keybinds_api_, keybinds_handle_, action); + }; + float forward = amount(keybinds::kWalkForward) - amount(keybinds::kWalkBack); + float side = amount(keybinds::kWalkRight) - amount(keybinds::kWalkLeft); + const float crouch = amount(keybinds::kCrouch); + const float length = std::hypot(forward, side); + if (length > 1.0f) { + forward /= length; + side /= length; + } + if (length > 0.05f) + walk_to_.reset(); // the player takes over + if (forward < 0.0f) + forward *= kBackwardShare; + side *= kSidewaysShare; + + // Relative to where the player looks: yaw 0 looks toward -z, positive + // yaw turns left (toward -x, flipped in a mirrored camera, see CameraX). + const float speed = kWalkSpeed * (1.0f - kCrouchSlowdown * crouch); + float want_x = CameraX() * + (-std::sin(base_yaw_) * forward + std::cos(base_yaw_) * side) * + speed; + float want_z = + (-std::cos(base_yaw_) * forward - std::sin(base_yaw_) * side) * speed; + + if (walk_to_) { + const Vec3 target = Approach(*walk_to_); + const float dx = target.x - pos_.x, dz = target.z - pos_.z; + const float distance = std::hypot(dx, dz); + if (distance < kArriveMeters) { + const Spot spot = *walk_to_; + walk_to_.reset(); + SitAt(spot); + return; + } + const float approach = std::min(kWalkSpeed, distance * kArriveGain); + want_x = dx / distance * approach; + want_z = dz / distance * approach; + } + + // Getting up to speed, or slowing down and stopping (quicker). + const bool speeding_up = + std::hypot(want_x, want_z) >= std::hypot(vel_x_.value(), vel_z_.value()); + const float response = + speeding_up ? kAccelerationSeconds : kDecelerationSeconds; + vel_x_.SetTimeConstant(response); + vel_z_.SetTimeConstant(response); + float vx = vel_x_.Update(want_x, dt); + float vz = vel_z_.Update(want_z, dt); + pos_.x = ClampToFloor(pos_.x + vx * dt, layout_.floor_x_min, + layout_.floor_x_max, vx, vel_x_); + pos_.z = ClampToFloor(pos_.z + vz * dt, layout_.floor_z_min, + layout_.floor_z_max, vz, vel_z_); + + pos_.y = height_.Update(layout_.stand_y - crouch * layout_.crouch_depth, dt); +} + +// --- Lifecycle --- + +void CabinWalkEffect::OnTruckConstantsChanged( + const SPF_TruckConstants &constants) { + const std::string key = TruckLayoutKey(constants); + const bool right_hand_drive = 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_ && + centerline_x == centerline_x_) + return; + truck_key_ = key; + right_hand_drive_ = right_hand_drive; + camera_override_.SetRightHandDrive(right_hand_drive); + centerline_x_ = centerline_x; + truck_name_ = std::string(constants.brand) + " " + constants.name; + LoadLayout(); + if (!AtWheel()) + SnapToWheel(); +} + +void CabinWalkEffect::OnLeftInterior() { + SetWalkKeysBlocked(false); + left_interior_ = true; +} + +void CabinWalkEffect::SnapToWheel() { + state_ = State::kSeated; + spot_ = Spot::kDriver; + walk_to_.reset(); + pos_ = base_; + base_rotation_.reset(); + motion_pitch_deg_ = 0.0f; + motion_roll_deg_ = 0.0f; + camera_override_.Forget(); + SetMouseBlocked(false); + SetWalkKeysBlocked(false); +} + +void CabinWalkEffect::Shutdown(SPF_Camera_API *camera) { + if (camera) + camera_override_.Restore(camera); + SetMouseBlocked(false); + SetWalkKeysBlocked(false); +} + +CabinWalkEffect::Notice CabinWalkEffect::TakeNotice() { + return std::exchange(notice_, Notice::kNone); +} + +void CabinWalkEffect::SetMouseBlocked(bool blocked) { + if (blocked == mouse_blocked_ || !ui_api_) + return; + // Only the look axes: the view is being turned for the player. + ui_api_->UI_SetMouseBlockState(blocked, false, false); + mouse_blocked_ = blocked; +} + +void CabinWalkEffect::SetWalkKeysBlocked(bool blocked) { + if (blocked == walk_keys_blocked_ || !keybinds_api_ || !keybinds_handle_) + return; + // Takes effect from the next press: a key already held when the player + // stands up keeps driving until released. + for (const keybinds::Action &action : keybinds::kWalkActions) + keybinds_api_->Kbind_SetBlockState(keybinds_handle_, action.id, blocked); + walk_keys_blocked_ = blocked; +} + +// --- Layout tuning --- + +void CabinWalkEffect::HandleGoTo(Spot spot, const SPF_TruckData &truck, + SPF_Camera_API *camera) { + if (state_ == State::kTransition || !CanSitAt(spot)) + return; + if (AtWheel()) { + TryStandUp(truck, camera); + if (state_ != State::kTransition) + return; // refused, the notice says why + } else if (state_ == State::kSeated) { + if (spot_ == spot) + return; + StandUp(); + } + walk_to_ = spot; +} + +void CabinWalkEffect::SetLayout(const CabinLayout &layout, bool save) { + layout_ = layout; + // Sat in (or walking to) a spot just marked unusable: back on the feet. + if (state_ == State::kSeated && !CanSitAt(spot_)) + StandUp(); + if (walk_to_ && !CanSitAt(*walk_to_)) + walk_to_.reset(); + if (save) + SaveLayout(); +} + +void CabinWalkEffect::ResetLayout() { + if (layouts_ && custom_layout_) + layouts_->Forget(truck_key_); + LoadLayout(); +} + +void CabinWalkEffect::LoadLayout() { + // One layout per truck model, whichever its cabin or side: the player's + // own, else the preset, else one built around the centerline. Made for + // left-hand drive, it has the right positions for a right-hand drive + // truck's mirrored camera, but its looks turn the wrong way. + auto fit = [&](const CabinLayout &layout) { + return right_hand_drive_ ? WithMirroredYaws(layout) : layout; + }; + std::optional custom, preset; + if (layouts_ && !truck_key_.empty()) { + custom = layouts_->LoadCustom(truck_key_); + preset = layouts_->LoadPreset(truck_key_); + } + shipped_layout_ = fit(preset.value_or(DefaultCabinLayout(centerline_x_))); + layout_ = custom ? fit(*custom) : shipped_layout_; + custom_layout_ = custom.has_value(); +} + +void CabinWalkEffect::SaveLayout() { + NormalizeCabinLayout(layout_); + custom_layout_ = true; + if (truck_key_.empty() || !layouts_) + return; + // Saved as for left-hand drive, so both sides get it: a right-hand drive + // truck's looks turned back (see CabinLayout). + layouts_->Save(truck_key_, + right_hand_drive_ ? WithMirroredYaws(layout_) : layout_); +} + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinWalkEffect.hpp b/src/effects/manual/cabin_walk/CabinWalkEffect.hpp new file mode 100644 index 0000000..abe8657 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinWalkEffect.hpp @@ -0,0 +1,275 @@ +#pragma once + +#include "CabinLayouts.hpp" +#include "InteriorCameraOverride.hpp" +#include "SPF_Camera_API.h" +#include "SPF_KeyBinds_API.h" +#include "SPF_UI_API.h" +#include "core/CameraRig.hpp" +#include "core/SettingsSchema.hpp" +#include "effects/ConfigurableEffect.hpp" +#include "math/SpringDamper.hpp" + +#include +#include +#include +#include + +namespace motioncab { + +// Based on SPF_CabinWalk by Track'n'Truck Devs +// (https://github.com/TrackAndTruckDevs/SPF_CabinWalk), +// +// Gets the player out of the driver's seat to walk around the cabin, parked +// with the parking brake on. The stand/sit key stands up; then the walking +// keys move the head freely over the cabin floor, relative to where the +// player looks, with crouching and a light step bob. Tapping +// stand/sit again sits down at the spot looked toward (driver's seat, +// passenger's, or the bunk's edge), walking there first if needed; holding +// it goes back to the wheel. Releasing the parking brake or rolling brings +// the player back to the wheel. +// +// Not a regular Effect: it needs the player's own pose (to walk from the +// seat they set up and to turn their view when sitting down) and owns +// camera state beyond a HeadOffset (rotation limits, mouse). +// Its offset is added to EffectManager's by OnUpdate, which fades the +// driving effects out meanwhile (EffectManager::SetAtWheel). +// +// The cabin itself (floor, heights, spots) comes from a per-truck +// CabinLayout, tuned from the Quick Settings. +class CabinWalkEffect { +public: + enum class Spot { kDriver, kPassenger, kBunkSit }; + // Why the stand/sit key was refused, or the player was sent back to the + // wheel, for OnUpdate to tell them. + enum class Notice { kNone, kNeedsParkingBrake, kNeedsStop, kBackToWheel }; + + CabinWalkEffect(SPF_Config_API *config_api, SPF_Config_Handle *config_handle, + SPF_KeyBinds_API *keybinds_api, + SPF_KeyBinds_Handle *keybinds_handle, SPF_UI_API *ui_api, + CabinLayoutStore *layouts); + + bool IsEnabled() const { return enabled_; } + void LoadConfig(); + + // Advances by `dt` seconds. `base` is the player's own pose underneath + // every offset (CameraRig::Base). Returns the offset to add to the + // effects' one. + HeadOffset Update(float dt, const SPF_TruckData &truck, + SPF_Camera_API *camera, const CameraRig::Pose &base); + + // The player's own head rotation (radians) this frame's write must use, + // while sitting down turns their view (CameraRig::SetBaseRotation). + const std::optional> &base_rotation() const { + return base_rotation_; + } + + // Settled in the driver's seat: nothing overridden, driving effects on. + bool AtWheel() const { + return state_ == State::kSeated && spot_ == Spot::kDriver; + } + + // Loads the new truck's layout. A player away from the wheel is put back + // in the seat at once, since the cabin they were in is gone. + void OnTruckConstantsChanged(const SPF_TruckConstants &constants); + + // Out of the interior view: the game gets the walking keys back + // meanwhile, to drive from another camera. Update() blocks them again + // once back in the cabin, and puts the player straight back at the wheel + // if the truck was set rolling meanwhile. + void OnLeftInterior(); + + // Back in the driver's seat at once, leaving the camera's values to the + // game: after a world reload they belong to a camera that no longer + // exists. + void SnapToWheel(); + + // Plugin unload: gives the camera's own values and the mouse back. + void Shutdown(SPF_Camera_API *camera); + + // The notice raised since the last call, if any. + Notice TakeNotice(); + + // The footsteps to play since the last call, as their volumes (0..1), + // for OnUpdate to hand to CabinWalkSounds. + std::vector TakeFootsteps() { return std::exchange(footsteps_, {}); } + + // --- Layout tuning (Quick Settings) --- + + // Takes the player to sit at `spot`, to check or tune its values + // from there: from the wheel too, standing up first if allowed (parked). + // Done on the next Update(), which has the truck and camera it needs. + void GoToSpot(Spot spot) { go_to_request_ = spot; } + + bool truck_known() const { return !truck_key_.empty(); } + const std::string &truck_name() const { return truck_name_; } + const CabinLayout &layout() const { return layout_; } + // The layout is the player's own, not the one shipped for the truck. + bool custom_layout() const { return custom_layout_; } + // The layout the plugin ships for this truck (its preset, else the one + // built around its centerline), under the player's own: what a layout + // value resets to. + const CabinLayout &shipped_layout() const { return shipped_layout_; } + // Uses `layout` for this truck, saved unless `save` is false (a slider + // still being dragged). + void SetLayout(const CabinLayout &layout, bool save = true); + // Drops the player's layout for this truck, back to the preset shipped + // with the plugin, or the default one. + void ResetLayout(); + +private: + enum class State { kSeated, kTransition, kWalking }; + // How a transition moves the head (see StartTransition): standing up from + // a seat, sitting down onto one, or moving between two seated spots. + enum class Path { kRise, kSit, kDirect }; + + struct Vec3 { + float x, y, z; + }; + struct SpotPose { + Vec3 pos; + float yaw_rad, pitch_rad; + }; + + SpotPose SpotAt(Spot spot) const; + // Where the player sitting at `spot` faces, on the floor plane. + Vec3 Facing(Spot spot) const; + // Where the player stands to get in or out of `spot`: straight above it, + // pulled into the floor area. + Vec3 Approach(Spot spot) const; + +public: + // False for a spot this truck's layout marks unusable (a folded-up + // passenger seat, no bunk). + bool CanSitAt(Spot spot) const { + switch (spot) { + case Spot::kPassenger: + return layout_.has_passenger; + case Spot::kBunkSit: + return layout_.has_bunk; + case Spot::kDriver: + break; + } + return true; + } + +private: + // The seat the player looks toward, for a tap while standing. + Spot LookedAtSpot() const; + + void HandleStandSitKey(float dt, const SPF_TruckData &truck, + SPF_Camera_API *camera); + void TryStandUp(const SPF_TruckData &truck, SPF_Camera_API *camera); + void HandleGoTo(Spot spot, const SPF_TruckData &truck, + SPF_Camera_API *camera); + void StandUp(); + void SitAt(Spot spot); + void GoToWheel(float duration); + void StartTransition(const Vec3 &to, Path path, float duration, State after, + Spot after_spot, bool turn); + float TransitionDuration(const Vec3 &to) const { + return TransitionDuration(pos_, to); + } + float TransitionDuration(const Vec3 &from, const Vec3 &to) const; + void UpdateTransition(float dt, SPF_Camera_API *camera); + // Cubic Bezier through p0..p3 at `u` (0..1). + static Vec3 Bezier(const Vec3 &p0, const Vec3 &p1, const Vec3 &p2, + const Vec3 &p3, float u); + void OnArrived(SPF_Camera_API *camera); + // Seated at a layout spot: follows its values live, so a layout slider + // moves (and turns) the player sitting there right away. + void FollowSeatedSpot(SPF_Camera_API *camera); + void UpdateWalking(float dt); + float ClampToFloor(float value, float min, float max, float &velocity, + math::SpringDamper1D &spring) const; + + void SetMouseBlocked(bool blocked); + // The walking and crouch keys, which the game uses at the wheel, hidden + // from it. + void SetWalkKeysBlocked(bool blocked); + void SaveLayout(); + // -1 on a right-hand drive truck, whose camera mirrors the seat position + // left to right but not the head's yaw (seen in game): a direction worked + // out from the yaw then has its x flipped to move the seat that way. + float CameraX() const { return right_hand_drive_ ? -1.0f : 1.0f; } + // Picks this truck's layout: the player's, else the preset, else one + // built around its centerline. + void LoadLayout(); + + EffectConfig config_; + SPF_KeyBinds_API *keybinds_api_; + SPF_KeyBinds_Handle *keybinds_handle_; + SPF_UI_API *ui_api_; + CabinLayoutStore *layouts_; + + bool enabled_ = true; + bool require_parking_brake_ = + settings::DefaultBool("settings.manual.cabin_walk.require_parking_brake"); + + State state_ = State::kSeated; + Spot spot_ = Spot::kDriver; // the seat, while kSeated + Vec3 pos_{}; // the head, without the step bob + // The player's own seat position and head rotation, last frame. + Vec3 base_{}; + float base_yaw_ = 0.0f, base_pitch_ = 0.0f; + std::optional> base_rotation_; + + // Current transition. + Vec3 from_{}, to_{}; + Path path_ = Path::kDirect; + float progress_ = 0.0f, duration_ = 1.0f; // seconds + State after_ = State::kWalking; + Spot after_spot_ = Spot::kDriver; + bool turn_ = false; // turns the view to the spot's look + float yaw_from_ = 0.0f, pitch_from_ = 0.0f; + // The head's path: a cubic Bezier from from_ to to_ through ctrl1_ and + // to_ + ctrl2_offset_ (relative, since to_ follows a seat that moves). + Vec3 ctrl1_{}, ctrl2_offset_{}; + // Sideways share of the move relative to the view, -1 (left) to 1 + // (right), for the head's lean on the way to a front seat (0 otherwise). + float lean_side_ = 0.0f; + // The body's own motion on top of the path, in degrees: looking down at + // where it steps or sits, leaning into the move. + float motion_pitch_deg_ = 0.0f, motion_roll_deg_ = 0.0f; + // The seated spot's look (degrees) last applied, to notice an edit. + float seated_yaw_deg_ = 0.0f, seated_pitch_deg_ = 0.0f; + + // Walking: m/s along x and z, head height. + math::SpringDamper1D vel_x_, vel_z_, height_; + std::optional walk_to_; // walking by itself to sit there + std::optional go_to_request_; // from GoToSpot, for Update() + math::SpringDamper1D bob_amount_; // 0 = still, 1 = walking speed + // 0 = with the cabin, 1 = upright: its last value, and at the start of + // the current transition. + float upright_now_ = 0.0f, upright_from_ = 0.0f; + float bob_phase_ = 0.0f; // radians, one half-turn per step + + // The stand/sit key: a tap acts on release, a hold once held long enough. + bool stand_sit_held_ = false; + bool stand_sit_long_done_ = false; + float stand_sit_held_time_ = 0.0f; + + InteriorCameraOverride camera_override_; + // The camera's re-activation (InteriorCameraOverride::refreshes) puts the + // head back to its default rotation shortly after: the rotation from + // before is held for a moment instead. + int seen_refreshes_ = 0; + float hold_rotation_time_ = 0.0f; // seconds left + std::pair held_rotation_{}; + bool mouse_blocked_ = false; + bool walk_keys_blocked_ = false; + bool left_interior_ = false; // since the last Update() + Notice notice_ = Notice::kNone; + std::vector footsteps_; + + std::string truck_key_, truck_name_; + CabinLayout layout_ = DefaultCabinLayout(); + bool custom_layout_ = false; + CabinLayout shipped_layout_ = DefaultCabinLayout(); + bool right_hand_drive_ = false; + // The cabin's centerline: the passenger seat mirrors the player's across + // it, and an uncalibrated truck's layout is built around it. + float centerline_x_ = kTypicalCenterlineX; +}; + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinWalkSounds.cpp b/src/effects/manual/cabin_walk/CabinWalkSounds.cpp new file mode 100644 index 0000000..46d94ae --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinWalkSounds.cpp @@ -0,0 +1,81 @@ +#include "CabinWalkSounds.hpp" + +#include "core/PluginContext.hpp" + +namespace motioncab { + +namespace { + +constexpr const char *kBankFile = "MotionCab.bank"; +constexpr const char *kGuidsFile = "MotionCab.bank.guids"; + +constexpr const char *kFootstepEvent = "event:/motioncab/footsteps"; + +// Footsteps vary a little so that no two sound alike: pitch (a ratio) and +// volume ranges. +constexpr float kStepPitchMin = 0.93f, kStepPitchMax = 1.07f; +constexpr float kStepVolumeMin = 0.8f; + +} // namespace + +void CabinWalkSounds::Load(SPF_Sound_API *sound) { + PluginContext &ctx = Context(); + const std::string bank = dir_ + "/" + kBankFile; + const std::string guids = dir_ + "/" + kGuidsFile; + // SPF reads the .guids file itself, which names the bank's events: they + // are then found by path. + bank_ = sound->SND_LoadBankFile(bank.c_str(), guids.c_str()); + if (!bank_) + ctx.LogFmt(SPF_LOG_WARN, "(CabinWalk) %s not loaded", kBankFile); +} + +void CabinWalkSounds::Update(SPF_Sound_API *sound) { + if (!sound) + return; + if (!sound->SND_IsReady()) { + // The world is gone, and the bank's handle with it. + ready_ = false; + bank_ = nullptr; + return; + } + if (!ready_) { + ready_ = true; + if (!dir_.empty()) + Load(sound); + } +} + +void CabinWalkSounds::PlayFootstep(SPF_Sound_API *sound, float volume) { + if (!sound || !ready_ || !bank_) + return; + // Not kept: SPF renumbers the events whenever a bank is loaded or unloaded. + const int event = sound->SND_FindEventIndexByPath(kFootstepEvent); + if (event < 0) { + Context().LogThrottledFmt(SPF_LOG_WARN, "cabin_walk_sound_missing", 10000, + "(CabinWalk) %s not found", kFootstepEvent); + return; + } + void *instance = sound->SND_CreateEventInstance(event); + if (!instance) + return; + volume *= + std::uniform_real_distribution(kStepVolumeMin, 1.0f)(random_); + const float pitch = std::uniform_real_distribution( + kStepPitchMin, kStepPitchMax)(random_); + sound->SND_SetEventVolume(instance, volume); + sound->SND_SetEventPitch(instance, pitch); + sound->SND_StartEvent(instance); + // Freed by FMOD once it has finished playing. + sound->SND_ReleaseEvent(instance); +} + +void CabinWalkSounds::Shutdown(SPF_Sound_API *sound) { + if (sound && ready_ && sound->SND_IsReady()) { + if (bank_) + sound->SND_UnloadBank(bank_); + } + ready_ = false; + bank_ = nullptr; +} + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/CabinWalkSounds.hpp b/src/effects/manual/cabin_walk/CabinWalkSounds.hpp new file mode 100644 index 0000000..fa4ad47 --- /dev/null +++ b/src/effects/manual/cabin_walk/CabinWalkSounds.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "SPF_Sound_API.h" + +#include +#include + +namespace motioncab { + +// Plays Cabin Walk's footsteps through the game's FMOD system (SPF's sound +// API), from MotionCab's own bank in data/sounds/, built with FMOD Studio: +// MotionCab.bank with its event:/motioncab/footsteps event, and +// MotionCab.bank.guids (FMOD Studio's exported GUIDs.txt), which names the +// events' GUIDs. The events must output to a bus of the game's (the game +// refuses a second master bank): a bank routed to its own project's master +// bus loads, but stays silent. The FMOD project's master bus has the GUID +// of the game's bus:/cabin/interior ({7fe623a8-a2f0-4ba8-ad2a-9cad68432851}, +// read from the game's master bank), where the cabin's own sounds go: +// bus:/outside/* would muffle them like the world heard from inside. +// +// The sound system only exists while a game world is loaded, so the bank is +// loaded once it's ready, and again after a world reload. SPF reads the +// .guids file along with the bank, so the events are found by path. +class CabinWalkSounds { +public: + // `dir`: the folder holding the bank files. + void SetDirectory(std::string dir) { dir_ = std::move(dir); } + + // Loads the bank once the sound system is ready. Every frame. + void Update(SPF_Sound_API *sound); + + // `volume`: 0..1. + void PlayFootstep(SPF_Sound_API *sound, float volume); + + // Plugin unload: unloads the bank, so a reloaded plugin can load it + // again. + void Shutdown(SPF_Sound_API *sound); + +private: + void Load(SPF_Sound_API *sound); + + std::string dir_; + bool ready_ = false; // loaded for the current sound system + void *bank_ = nullptr; + std::minstd_rand random_{std::random_device{}()}; +}; + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/InteriorCameraOverride.cpp b/src/effects/manual/cabin_walk/InteriorCameraOverride.cpp new file mode 100644 index 0000000..52ef5bc --- /dev/null +++ b/src/effects/manual/cabin_walk/InteriorCameraOverride.cpp @@ -0,0 +1,154 @@ +#include "InteriorCameraOverride.hpp" + +#include "math/Units.hpp" + +#include + +namespace motioncab { + +bool InteriorCameraOverride::Engage(SPF_Camera_API *camera) { + if (engaged_) + return true; + if (!camera->Cam_GetInteriorRotationLimits(&limits_.left, &limits_.right, + &limits_.up, &limits_.down) || + !camera->Cam_GetInteriorRotationDefaults(&default_yaw_deg_, + &default_pitch_deg_)) + return false; + + float look_yaw_rad = 0.0f, look_pitch_rad = 0.0f; + camera->Cam_GetInteriorHeadRot(&look_yaw_rad, &look_pitch_rad); + + const size_t count = camera->Cam_GetInteriorAzimuthOverridesCount(); + azimuths_.assign(count, Azimuth{}); + for (size_t i = 0; i < count; ++i) { + Azimuth &a = azimuths_[i]; + a.valid = camera->Cam_GetInteriorAzimuthOverrideStartAzimuth(i, &a.start) && + camera->Cam_GetInteriorAzimuthOverrideEndAzimuth(i, &a.end) && + camera->Cam_GetInteriorAzimuthOverrideOutside(i, &a.outside) && + camera->Cam_GetInteriorAzimuthOverrideStartHeadOffset( + i, &a.start_head[0], &a.start_head[1], &a.start_head[2]) && + camera->Cam_GetInteriorAzimuthOverrideEndHeadOffset( + i, &a.end_head[0], &a.end_head[1], &a.end_head[2]); + if (!a.valid) + continue; + // An empty angle range with no head offset: the override never kicks + // in (how SPF_CabinWalk disables them, confirmed in-game there). + camera->Cam_SetInteriorAzimuthOverrideStartAzimuth(i, 0.0f); + camera->Cam_SetInteriorAzimuthOverrideEndAzimuth(i, 0.0f); + camera->Cam_SetInteriorAzimuthOverrideOutside(i, false); + camera->Cam_SetInteriorAzimuthOverrideStartHeadOffset(i, 0.0f, 0.0f, 0.0f); + camera->Cam_SetInteriorAzimuthOverrideEndHeadOffset(i, 0.0f, 0.0f, 0.0f); + } + // Nothing close to the eyes clipped away (a headrest when looking back + // from the passenger seat, a wall walked up to). + has_near_plane_ = camera->Cam_GetInteriorNearPlane(&near_plane_); + engaged_ = true; + // The zones are the left-hand drive cabin's: a right-hand drive camera + // evaluates them at the mirrored yaw (as it does the defaults, see + // SetRightHandDrive). Their offsets need no mirroring, being in the seat + // frame, which the camera already mirrors. + const float look_yaw_deg = look_yaw_rad / math::kDegToRad; + engage_head_offset_ = + HeadOffsetAt(right_hand_drive_ ? -look_yaw_deg : look_yaw_deg); + RefreshSoundZones(camera); + return true; +} + +std::array InteriorCameraOverride::HeadOffsetAt(float yaw_deg) const { + std::array offset{}; + for (const Azimuth &a : azimuths_) { + if (!a.valid) + continue; + // Interpolated along the zone (which may run past 180 degrees). + const float lo = std::min(a.start, a.end), hi = std::max(a.start, a.end); + if (hi <= lo) + continue; + for (const float angle : {yaw_deg, yaw_deg + 360.0f, yaw_deg - 360.0f}) { + if (angle < lo || angle > hi) + continue; + const float t = (angle - a.start) / (a.end - a.start); + for (int k = 0; k < 3; ++k) + offset[k] = a.start_head[k] + (a.end_head[k] - a.start_head[k]) * t; + return offset; + } + } + return offset; +} + +void InteriorCameraOverride::SetLimits(SPF_Camera_API *camera, + const Limits &limits) { + if (engaged_) + camera->Cam_SetInteriorRotationLimits(limits.left, limits.right, limits.up, + limits.down); +} + +void InteriorCameraOverride::SetDefaults(SPF_Camera_API *camera, float yaw_deg, + float pitch_deg) { + if (engaged_) + camera->Cam_SetInteriorRotationDefaults(yaw_deg, pitch_deg); +} + +void InteriorCameraOverride::Restore(SPF_Camera_API *camera) { + if (!engaged_) + return; + camera->Cam_SetInteriorRotationLimits(limits_.left, limits_.right, limits_.up, + limits_.down); + camera->Cam_SetInteriorRotationDefaults(default_yaw_deg_, default_pitch_deg_); + const size_t count = camera->Cam_GetInteriorAzimuthOverridesCount(); + for (size_t i = 0; i < count && i < azimuths_.size(); ++i) { + const Azimuth &a = azimuths_[i]; + if (!a.valid) + continue; + camera->Cam_SetInteriorAzimuthOverrideStartAzimuth(i, a.start); + camera->Cam_SetInteriorAzimuthOverrideEndAzimuth(i, a.end); + camera->Cam_SetInteriorAzimuthOverrideOutside(i, a.outside); + camera->Cam_SetInteriorAzimuthOverrideStartHeadOffset( + i, a.start_head[0], a.start_head[1], a.start_head[2]); + camera->Cam_SetInteriorAzimuthOverrideEndHeadOffset( + i, a.end_head[0], a.end_head[1], a.end_head[2]); + } + if (has_near_plane_) + camera->Cam_SetInteriorNearPlane(near_plane_); + Forget(); + RefreshSoundZones(camera); +} + +void InteriorCameraOverride::RefreshSoundZones(SPF_Camera_API *camera) { + SPF_CameraType type; + if (!camera->Cam_GetCurrentCamera(&type) || type != SPF_CAMERA_INTERIOR) + return; + // Re-activating recenters the head onto the rotation defaults: made the + // current look first, so that it doesn't move (EndRefresh puts them back). + float yaw_rad = 0.0f, pitch_rad = 0.0f; + if (camera->Cam_GetInteriorHeadRot(&yaw_rad, &pitch_rad)) + RecenterOnto(camera, yaw_rad, pitch_rad); + camera->Cam_SwitchTo(SPF_CAMERA_INTERIOR); + ++refreshes_; +} + +void InteriorCameraOverride::RecenterOnto(SPF_Camera_API *camera, float yaw_rad, + float pitch_rad) { + const float yaw_deg = yaw_rad / math::kDegToRad; + camera->Cam_SetInteriorRotationDefaults( + right_hand_drive_ ? -yaw_deg : yaw_deg, pitch_rad / math::kDegToRad); +} + +void InteriorCameraOverride::EndRefresh(SPF_Camera_API *camera) { + // Both refreshes happen in the driver's seat, whose defaults are the + // truck's own (still known after Restore). + camera->Cam_SetInteriorRotationDefaults(default_yaw_deg_, default_pitch_deg_); +} + +void InteriorCameraOverride::KeepNearPlane(SPF_Camera_API *camera) { + // Every frame: the camera's re-activation may put its own back. + if (engaged_ && has_near_plane_) + camera->Cam_SetInteriorNearPlane(away_near_plane()); +} + +void InteriorCameraOverride::Forget() { + engaged_ = false; + has_near_plane_ = false; + azimuths_.clear(); +} + +} // namespace motioncab diff --git a/src/effects/manual/cabin_walk/InteriorCameraOverride.hpp b/src/effects/manual/cabin_walk/InteriorCameraOverride.hpp new file mode 100644 index 0000000..9cf515b --- /dev/null +++ b/src/effects/manual/cabin_walk/InteriorCameraOverride.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include "SPF_Camera_API.h" + +#include +#include + +namespace motioncab { + +// Swaps the interior camera's rotation limits and defaults for our own while +// the player is out of the driver's seat (Cabin Walk), and puts the +// truck's own values back afterwards. +// +// Engage() also empties the azimuth overrides, the truck's head offsets +// for given look angles (leaning toward a mirror, or out of the window): +// they only make sense from the driver's seat, and would shove a standing +// head around. The game's sound keeps its own copy of them, taken when the +// interior camera is activated, and mixes the cabin as heard from outside +// (FMOD's cabin_out) in the zones flagged "outside": so both Engage() and +// Restore() re-activate the camera for the copy to follow. +class InteriorCameraOverride { +public: + // Degrees, as Cam_Get/SetInteriorRotationLimits take them: left and up + // positive, right and down negative. + struct Limits { + float left, right, up, down; + }; + + // Saves the truck's values, the first time since the last Restore() or + // Forget(), and empties the azimuth overrides. False if the camera isn't + // resolved; nothing is changed then. + bool Engage(SPF_Camera_API *camera); + // The head offset the azimuth overrides were adding at the look angle of + // the last Engage() (leaning toward a mirror), in the seat's frame: gone + // since, so the caller starts from it to avoid a jump. + const std::array &engage_head_offset() const { + return engage_head_offset_; + } + // The head offset the truck's own azimuth overrides give at `yaw_deg` + // (leaning toward a mirror), in the seat's frame; zero where none + // applies. Only while engaged: the camera's own are emptied meanwhile. + std::array HeadOffsetAt(float yaw_deg) const; + // The interior camera's near clipping distance while engaged: a fraction + // of the truck's own, so a headrest or wall right by the eyes isn't + // clipped away. + float away_near_plane() const { return near_plane_ * kAwayNearPlaneShare; } + // Keeps the engaged near plane on the camera. Every frame while engaged. + void KeepNearPlane(SPF_Camera_API *camera); + + // Only while engaged. + void SetLimits(SPF_Camera_API *camera, const Limits &limits); + // Degrees, the head rotation the native recenter key snaps to. + void SetDefaults(SPF_Camera_API *camera, float yaw_deg, float pitch_deg); + + // Puts the truck's values back, if engaged. + void Restore(SPF_Camera_API *camera); + + // Drops the saved values without writing them: after a truck change or a + // world reload, when they belong to a camera that no longer exists. + void Forget(); + + // A right-hand drive camera turns the head to the opposite of the default + // yaw when re-activated (seen in game), so a refresh writes it mirrored. + void SetRightHandDrive(bool right_hand_drive) { + right_hand_drive_ = right_hand_drive; + } + + bool engaged() const { return engaged_; } + // Counts the camera's re-activations. Each one points the rotation + // defaults at the current look for the recenter it causes a frame or so + // later; the caller holds the look meanwhile, then calls EndRefresh() to + // put the truck's defaults back. + int refreshes() const { return refreshes_; } + void EndRefresh(SPF_Camera_API *camera); + // The truck's own values; valid only while engaged. + const Limits &original_limits() const { return limits_; } + float original_default_yaw() const { return default_yaw_deg_; } + float original_default_pitch() const { return default_pitch_deg_; } + +private: + // Re-activates the interior camera, if it's the current one, so the + // game's sound takes a new copy of the azimuth overrides (seen in game: + // a view switch and back ends the outside mix). + void RefreshSoundZones(SPF_Camera_API *camera); + // Sets the rotation defaults so that the camera's next activation + // recenters the head onto this look (radians, as the head rotation), + // mirrored for a right-hand drive camera (see SetRightHandDrive). + void RecenterOnto(SPF_Camera_API *camera, float yaw_rad, float pitch_rad); + + struct Azimuth { + bool valid; + float start, end; + bool outside; + float start_head[3], end_head[3]; + }; + + static constexpr float kAwayNearPlaneShare = 0.25f; + + bool engaged_ = false; + bool right_hand_drive_ = false; + float near_plane_ = 0.0f; + bool has_near_plane_ = false; + int refreshes_ = 0; + std::array engage_head_offset_{}; + Limits limits_{}; + float default_yaw_deg_ = 0.0f, default_pitch_deg_ = 0.0f; + std::vector azimuths_; +}; + +} // namespace motioncab diff --git a/src/ui/CabinLayoutPanel.cpp b/src/ui/CabinLayoutPanel.cpp new file mode 100644 index 0000000..8fa7c96 --- /dev/null +++ b/src/ui/CabinLayoutPanel.cpp @@ -0,0 +1,221 @@ +#include "CabinLayoutPanel.hpp" + +#include "SPF_Icons.h" +#include "core/Localization.hpp" +#include "core/PluginContext.hpp" +#include "effects/manual/cabin_walk/CabinLayouts.hpp" +#include "ui/Widgets.hpp" + +#include +#include +#include +#include + +namespace motioncab::ui { + +namespace { + +constexpr const char *kResetPopupId = "###confirm_reset_cabin_layout"; + +using Spot = CabinWalkEffect::Spot; + +const CabinLayoutField &FieldNamed(std::string_view name) { + for (const CabinLayoutField &field : CabinLayoutFields()) + if (name == field.name) + return field; + return CabinLayoutFields().front(); +} + +std::string Label(const std::string &key) { return loc::Tr(key.c_str()); } + +// A value's tooltip: what it moves and which way. Both seats' looks share +// theirs. +std::string Tip(std::string_view name) { + std::string_view tip = name; + if (name.ends_with("_yaw")) + tip = "yaw"; + else if (name.ends_with("_pitch")) + tip = "pitch"; + return Label("ui.cabin_walk.layout.tip." + std::string(tip)); +} + +// A value's row: its name, then [-] value [+] (StepperFloat). The value is +// in centimeters (meters stored) or degrees, stepped by 1 cm or 1 degree, +// dragged, typed with Ctrl+click, or right-clicked back to the shipped +// layout's. Applies live; saved when let go. +void DrawValueRow(SPF_UI_API *ui, CabinWalkEffect &walk, const char *name, + const std::string &label, bool indent = false) { + const CabinLayoutField &field = FieldNamed(name); + CabinLayout layout = walk.layout(); + float &value = layout.*field.member; + const float scale = field.angle ? 1.0f : 100.0f; + const char *format = field.angle ? "%.0f\xc2\xb0" : "%.1f cm"; + + ui->UI_TableNextRow(SPF_TABLE_ROW_FLAG_NONE, 0.0f); + ui->UI_TableNextColumn(); + if (indent) + ui->UI_Indent(12.0f); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text(label.c_str()); + if (indent) + ui->UI_Unindent(12.0f); + ui->UI_TableNextColumn(); + float shown = value * scale; + const float shipped = walk.shipped_layout().*field.member; + const std::string tip = Tip(name); + switch (StepperFloat(ui, name, &shown, field.min * scale, field.max * scale, + 1.0f, format, shipped * scale, tip.c_str(), true)) { + case StepperEdit::kLive: + value = shown / scale; + walk.SetLayout(layout, false); + break; + case StepperEdit::kDone: + value = shown / scale; + walk.SetLayout(layout); + break; + case StepperEdit::kNone: + break; + } +} + +// A table of rows: the names' column as wide as they need. +bool BeginRows(SPF_UI_API *ui, const char *id) { + if (!ui->UI_BeginTable(id, 2, SPF_TABLE_FLAG_NONE, 0.0f, 0.0f, 0.0f)) + return false; + ui->UI_TableSetupColumn("name", SPF_TABLE_COLUMN_FLAG_WIDTH_FIXED, 0.0f, 0); + ui->UI_TableSetupColumn("value", SPF_TABLE_COLUMN_FLAG_WIDTH_STRETCH, 0.0f, + 0); + return true; +} + +// Going to sit at a seat (standing up first if at the wheel), to tune it +// from there: a row of its own, the buttons where the values start. +void DrawGoToRow(SPF_UI_API *ui, CabinWalkEffect &walk) { + ui->UI_TableNextRow(SPF_TABLE_ROW_FLAG_NONE, 0.0f); + ui->UI_TableNextColumn(); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text(loc::Tr("ui.cabin_walk.layout.go")); + ui->UI_TableNextColumn(); + bool first = true; + for (const auto &[spot, group] : {std::pair{Spot::kPassenger, "passenger"}, + std::pair{Spot::kBunkSit, "bunk"}}) { + if (!first) + ui->UI_SameLine(0.0f, 6.0f); + first = false; + const std::string go = + WithIcon(ICON_FA_PERSON_WALKING, + Label(std::string("ui.cabin_walk.layout.group.") + group)) + + "###go_" + group; + ui->UI_BeginDisabled(!walk.CanSitAt(spot)); + if (MutedButton(ui, go.c_str())) + walk.GoToSpot(spot); + ui->UI_EndDisabled(); + } +} + +// Whether a seat can be sat in: a passenger seat folded up, a day cab's +// missing bunk. +void DrawUsableRow(SPF_UI_API *ui, CabinWalkEffect &walk, Spot spot) { + CabinLayout layout = walk.layout(); + bool &usable = + spot == Spot::kPassenger ? layout.has_passenger : layout.has_bunk; + ui->UI_TableNextRow(SPF_TABLE_ROW_FLAG_NONE, 0.0f); + ui->UI_TableNextColumn(); + ui->UI_Indent(12.0f); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text(loc::Tr("ui.cabin_walk.layout.usable")); + ui->UI_Unindent(12.0f); + ui->UI_TableNextColumn(); + const std::string id = std::string("###usable_") + + (spot == Spot::kPassenger ? "passenger" : "bunk"); + if (ui->UI_Checkbox(id.c_str(), &usable)) + walk.SetLayout(layout); + ui->UI_SetItemTooltip( + Tip(spot == Spot::kPassenger ? "has_passenger" : "has_bunk").c_str()); +} + +// The truck, and dropping the player's own layout for it (only then). +void DrawTruckRow(SPF_UI_API *ui, CabinWalkEffect &walk) { + ui->UI_TableNextRow(SPF_TABLE_ROW_FLAG_NONE, 0.0f); + ui->UI_TableNextColumn(); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text(loc::Tr("ui.cabin_walk.layout.truck")); + ui->UI_TableNextColumn(); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text(WithIcon(ICON_FA_TRUCK, walk.truck_name()).c_str()); + ui->UI_SameLine(0.0f, 12.0f); + const std::string reset = WithIcon(ICON_FA_ARROW_ROTATE_LEFT, + loc::Tr("ui.cabin_walk.layout.reset")) + + "###reset_cabin_layout"; + ui->UI_BeginDisabled(!walk.custom_layout()); + if (MutedButton(ui, reset.c_str())) + ui->UI_OpenPopup(kResetPopupId, SPF_POPUP_FLAG_NONE); + ui->UI_EndDisabled(); + const std::string title = + loc::Tr("ui.cabin_walk.layout.reset_title") + std::string(kResetPopupId); + if (ConfirmPopup(ui, title, + loc::Tr("ui.cabin_walk.layout.reset_message", + {{"truck", walk.truck_name()}}) + .c_str(), + loc::Tr("ui.cabin_walk.layout.reset_confirm"))) + walk.ResetLayout(); +} + +// The truck and going to a seat, then every value by part of the cabin: the +// walkway's limits, the standing height and crouch, then each seat (whether +// it can be used, where it is and where it looks). +void DrawValues(SPF_UI_API *ui, CabinWalkEffect &walk) { + if (!BeginRows(ui, "cabin_values")) + return; + auto title = [&](const char *group) { + ui->UI_TableNextRow(SPF_TABLE_ROW_FLAG_NONE, 0.0f); + ui->UI_TableNextColumn(); + ui->UI_AlignTextToFramePadding(); + ui->UI_Text( + Label(std::string("ui.cabin_walk.layout.group.") + group).c_str()); + }; + auto rows = [&](std::initializer_list names) { + for (const char *name : names) + DrawValueRow(ui, walk, name, + Label(std::string("ui.cabin_walk.layout.") + name), true); + }; + DrawTruckRow(ui, walk); + DrawGoToRow(ui, walk); + title("walkway"); + rows({"floor_z_min", "floor_z_max", "floor_x_min", "floor_x_max"}); + title("heights"); + DrawValueRow(ui, walk, "stand_y", Label("ui.cabin_walk.layout.passenger_dy"), + true); + rows({"crouch_depth"}); + title("passenger"); + DrawUsableRow(ui, walk, Spot::kPassenger); + rows({"passenger_dx", "passenger_dy", "passenger_dz", "passenger_yaw", + "passenger_pitch"}); + title("bunk"); + DrawUsableRow(ui, walk, Spot::kBunkSit); + rows({"bunk_sit_x", "bunk_sit_y", "bunk_sit_z", "bunk_sit_yaw", + "bunk_sit_pitch"}); + ui->UI_EndTable(); +} + +} // namespace + +void DrawCabinLayoutPanel(SPF_UI_API *ui) { + const PluginContext &ctx = Context(); + CabinWalkEffect *walk = ctx.cabin_walk.get(); + const std::string advanced = + WithIcon(ICON_FA_SLIDERS, loc::Tr("ui.cabin_walk.layout.advanced")) + + "###cabin_layout_advanced"; + if (!ui->UI_CollapsingHeader(advanced.c_str(), SPF_TREE_NODE_FLAG_NONE)) + return; + // Only from the cabin, the game running: paused (menus) or in another + // view, Cabin Walk doesn't run, so an edit wouldn't show. + if (!walk || !walk->truck_known() || !ctx.was_interior_last_frame) { + ui->UI_TextDisabled(loc::Tr("ui.cabin_walk.layout.no_truck")); + return; + } + + DrawValues(ui, *walk); +} + +} // namespace motioncab::ui diff --git a/src/ui/CabinLayoutPanel.hpp b/src/ui/CabinLayoutPanel.hpp new file mode 100644 index 0000000..ad91384 --- /dev/null +++ b/src/ui/CabinLayoutPanel.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "SPF_UI_API.h" + +namespace motioncab::ui { + +// Cabin Walk's per-truck cabin layout, folded under its settings +// ("Advanced"): the truck with a reset of the player's own layout, Go There +// for each seat, and a row per layout value. +void DrawCabinLayoutPanel(SPF_UI_API *ui); + +} // namespace motioncab::ui diff --git a/src/ui/EffectTabs.cpp b/src/ui/EffectTabs.cpp index fbed70f..d686b6d 100644 --- a/src/ui/EffectTabs.cpp +++ b/src/ui/EffectTabs.cpp @@ -4,6 +4,7 @@ #include "core/Keybinds.hpp" #include "core/Localization.hpp" #include "core/SettingsSchema.hpp" +#include "ui/CabinLayoutPanel.hpp" #include "ui/Widgets.hpp" #include @@ -129,7 +130,8 @@ void DrawBool(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, cfg->Cfg_SetBool(h, setting.key, value); } -// One label|slider row. Right-click the slider to reset it to default. +// One label|[-] slider [+] row (StepperFloat), stepped by the format's last +// decimal. Right-click the slider to reset it to default. // `display_scale` shows the value (and the slider's range) multiplied by // it, e.g. to show a setting stored in km/h in mph; the stored value // doesn't change. @@ -144,16 +146,11 @@ void DrawSlider(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, ui->UI_TableSetColumnIndex(0); ui->UI_Text(SettingTitle(key)); ui->UI_TableSetColumnIndex(1); - char hidden_label[112]; - std::snprintf(hidden_label, sizeof(hidden_label), "##%s", key); - ui->UI_SetNextItemWidth(-1.0f); - if (ui->UI_SliderFloat(hidden_label, &value, setting.min * display_scale, - setting.max * display_scale, format, - SPF_SLIDER_FLAG_NONE)) + if (StepperFloat(ui, key, &value, setting.min * display_scale, + setting.max * display_scale, FormatStep(format), format, + setting.default_value * display_scale, + SettingDesc(key)) != StepperEdit::kNone) cfg->Cfg_SetFloat(h, key, value / display_scale); - if (ui->UI_IsItemClicked(SPF_MOUSE_BUTTON_RIGHT)) - cfg->Cfg_SetFloat(h, key, setting.default_value); - ui->UI_SetItemTooltip(SettingDesc(key)); } // A slider row for a speed setting, stored in km/h but shown in the @@ -202,10 +199,15 @@ struct EffectUi { const char *prefix; // "settings.." bool has_hint; // shows the ".hint" text under its toggle std::span keybinds; + // Drawn under its settings, for what doesn't fit a settings row. + void (*extra)(SPF_UI_API *ui) = nullptr; }; constexpr keybinds::Action kManualLookKeybinds[] = {keybinds::kLookLeft, keybinds::kLookRight}; +constexpr keybinds::Action kCabinWalkKeybinds[] = { + keybinds::kStandSit, keybinds::kWalkForward, keybinds::kWalkBack, + keybinds::kWalkLeft, keybinds::kWalkRight, keybinds::kCrouch}; // Every effect, in display order; each tab shows its own group's. constexpr EffectUi kEffects[] = { @@ -232,6 +234,8 @@ constexpr EffectUi kEffects[] = { "settings.manual.blindspot_viewer", false, {&keybinds::kBlindspotPeek, 1}}, + {ICON_FA_PERSON_WALKING, "settings.manual.cabin_walk", true, + kCabinWalkKeybinds, &DrawCabinLayoutPanel}, }; // Every effect section needs its "enabled" toggle in settings::kAll. @@ -268,11 +272,14 @@ void DrawEffect(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, for (const keybinds::Action &action : effect.keybinds) DrawKeybindRow(ui, action); for (const settings::Setting &s : settings::kAll) { - if (settings::InEffect(s, effect.prefix) && !IsEnabledToggle(s)) - DrawSettingRow(ui, cfg, h, s); + if (!settings::InEffect(s, effect.prefix) || IsEnabledToggle(s)) + continue; + DrawSettingRow(ui, cfg, h, s); } EndSettingsTable(ui); } + if (effect.extra) + effect.extra(ui); ui->UI_EndDisabled(); } @@ -284,8 +291,7 @@ void UpdateLabelColumnWidth(SPF_UI_API *ui) { void DrawEffectTab(SPF_UI_API *ui, SPF_Config_API *cfg, SPF_Config_Handle *h, std::string_view group) { - const std::string group_prefix = - "settings." + std::string(group) + "."; + const std::string group_prefix = "settings." + std::string(group) + "."; for (const EffectUi &effect : kEffects) { if (std::string_view(effect.prefix).starts_with(group_prefix)) DrawEffect(ui, cfg, h, effect); diff --git a/src/ui/Widgets.cpp b/src/ui/Widgets.cpp index 45c38a2..e867088 100644 --- a/src/ui/Widgets.cpp +++ b/src/ui/Widgets.cpp @@ -4,7 +4,11 @@ #include "core/Localization.hpp" #include "core/PluginContext.hpp" +#include +#include +#include #include +#include namespace motioncab::ui { @@ -69,6 +73,54 @@ int PushBrandRounding(SPF_UI_API *ui) { return 7; } +StepperEdit StepperFloat(SPF_UI_API *ui, const char *id, float *value, + float min, float max, float step, const char *format, + float reset_value, const char *tooltip, bool drag) { + StepperEdit edit = StepperEdit::kNone; + // Square buttons at both ends, the value filling the width between. + const float button = ui->UI_GetFrameHeight(); + constexpr float kGap = 4.0f; + float avail_w = 0.0f, avail_h = 0.0f; + ui->UI_GetContentRegionAvail(&avail_w, &avail_h); + ui->UI_PushID_Str(id); + auto step_button = [&](const char *icon, float sign) { + if (MutedButton(ui, icon, button)) { + *value = std::clamp(*value + sign * step, min, max); + edit = StepperEdit::kDone; + } + }; + step_button(ICON_FA_MINUS "###minus", -1.0f); + ui->UI_SameLine(0.0f, kGap); + ui->UI_SetNextItemWidth(std::max(avail_w - 2.0f * (button + kGap), 40.0f)); + const bool moved = + drag ? ui->UI_DragFloat("###value", value, (max - min) / 500.0f, min, max, + format, SPF_SLIDER_FLAG_ALWAYS_CLAMP) + : ui->UI_SliderFloat("###value", value, min, max, format, + SPF_SLIDER_FLAG_ALWAYS_CLAMP); + if (moved && edit == StepperEdit::kNone) + edit = StepperEdit::kLive; + if (ui->UI_IsItemDeactivatedAfterEdit()) + edit = StepperEdit::kDone; + if (ui->UI_IsItemClicked(SPF_MOUSE_BUTTON_RIGHT)) { + *value = reset_value; + edit = StepperEdit::kDone; + } + if (tooltip) + ui->UI_SetItemTooltip(tooltip); + ui->UI_SameLine(0.0f, kGap); + step_button(ICON_FA_PLUS "###plus", 1.0f); + ui->UI_PopID(); + return edit; +} + +float FormatStep(const char *format) { + // "%.f": n decimals. + const char *dot = std::strstr(format, "%."); + if (!dot || !std::isdigit(static_cast(dot[2]))) + return 1.0f; + return std::pow(10.0f, -static_cast(dot[2] - '0')); +} + bool MutedButton(SPF_UI_API *ui, const char *label, float width) { PushMutedButtonColors(ui); const bool clicked = ui->UI_Button(label, width, 0.0f); diff --git a/src/ui/Widgets.hpp b/src/ui/Widgets.hpp index cae5ffc..76c4cca 100644 --- a/src/ui/Widgets.hpp +++ b/src/ui/Widgets.hpp @@ -29,6 +29,25 @@ int PushBrandRounding(SPF_UI_API *ui); // keybind buttons, instead of the brighter brand red of regular buttons. bool MutedButton(SPF_UI_API *ui, const char *label, float width = 0.0f); +// How a value was edited this frame (see StepperFloat). +enum class StepperEdit { + kNone, + kLive, // being dragged: apply it, save once let go + kDone, // stepped, reset, typed or let go: save it +}; + +// [-] value [+] filling the width: the value slid (or, with `drag`, +// dragged without a range bar), stepped by `step` with the buttons, +// Ctrl+click to type it, right-click back to `reset_value`. `tooltip`, if +// any, shows over the value. +StepperEdit StepperFloat(SPF_UI_API *ui, const char *id, float *value, + float min, float max, float step, const char *format, + float reset_value, const char *tooltip = nullptr, + bool drag = false); + +// The step a printf-style format shows: 1 for "%.0f", 0.01 for "%.2f". +float FormatStep(const char *format); + // " ", for the icon-prefixed labels used all over the window. std::string WithIcon(const char *icon, std::string_view text); From 1c1bfb42770e1e609d1b73262d3480bb8bfe7588 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:46:40 +0200 Subject: [PATCH 4/5] docs: document Cabin Walk --- CONTRIBUTE.md | 6 ++++++ README.md | 37 +++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 32f34d8..3c3f670 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -45,6 +45,12 @@ game's `plugins/spfPlugins/MotionCab/`. - Derive a new effect from `ConfigurableEffect` (`src/effects/ConfigurableEffect.hpp`), which handles its `enabled` toggle and reads its settings by name. +- 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. +- 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. - Reuse the shared helpers (`src/math/Units.hpp`, `src/effects/TelemetryUtil.hpp`, `src/effects/road/Surface.hpp`, `src/core/Keybinds.hpp`) rather than duplicating their logic in an diff --git a/README.md b/README.md index 7514db1..cd07ac8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ camera control, keybind rebinding, and the in-game settings UI. dies - **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 + seat or on the bunk - **Profiles** — create, save, and switch between named presets - **Localized** — available in 15 languages @@ -68,22 +70,23 @@ active while in the interior (cabin) camera view. ## Controls -| Action | Default key | Behavior | -| ---------------- | ----------- | ---------------------------------------------------------- | -| Look Left | Numpad / | Manual Look — hold or toggle to look left | -| Look Right | Numpad * | Manual Look — hold or toggle to look right | -| Zoom | Numpad - | Manual Zoom — hold to zoom in, release to return to normal | -| Blindspot Viewer | F10 | Toggle or hold to lean forward and look up | -| Toggle Window | F9 | Shows/hides the MotionCab Quick Settings window | - -All keys are fully rebindable in SPF's in-game Keybinds drawer. +| Action | Default key | Behavior | +| ------------------- | ----------- | ------------------------------------------------------------------------------------- | +| Toggle Window | F9 | Shows/hides the MotionCab Quick Settings window | +| Look Left | Numpad / | Manual Look — hold or toggle to look left | +| Look Right | Numpad * | Manual Look — hold or toggle to look right | +| Zoom | Numpad - | Manual Zoom — hold to zoom in, release to return to normal | +| Blindspot Viewer | F10 | Blindspot Viewer — Toggle or hold to lean forward and look up | +| Stand Up / Sit Down | Page Up | Cabin Walk — get up; tap to sit at the seat you look at, hold to go back to the wheel | +| Walk | W/S/A/D | Cabin Walk — walk forward, back and sideways while standing | +| Crouch | C | Cabin Walk — crouch while standing | ## Configuration All parameters are tuned live either through SPF's native in-game settings window, or through MotionCab's own **Quick Settings** window, which mirrors -the same settings and adds a few conveniences: right-click any slider to reset -it to default, and the **Settings** tab has a button to reset every effect at once. +the same settings and adds a few conveniences: right-click any slider to reset it +to default, and the **Settings** tab has a button to reset every effect at once. Values are persisted by the framework to `plugins/spfPlugins/MotionCab/config/settings.json`. | Setting | Default | Description | @@ -149,6 +152,8 @@ Values are persisted by the framework to `plugins/spfPlugins/MotionCab/config/se | `fov_offset_deg` | 10.0 | How much the field of view widens while peeking. Negative = narrower. | | `smoothing_time` | 0.6 | How smooth the lean-and-return motion is. | | `toggle_mode` | true | On: press once to peek, press again to sit back. Off: peek while held, let go to sit back. | +| **Cabin Walk** | | | +| `require_parking_brake` | true | On: needs the parking brake, releasing it sends you back. Off: stopping is enough. | Every effect also has its own `enabled` toggle, defaulting to on. @@ -201,15 +206,23 @@ game's `plugins/spfPlugins/MotionCab/`. │ │ │ Surface (ground classification) │ │ ├── cabin/ IdleBreathing, EngineVibration, EngineStartStop │ │ └── manual/ MirrorCheck, ManualLook, ManualZoom, BlindspotViewer +│ │ └── cabin_walk/ +│ │ ├── CabinWalkEffect.* The effect: standing up, walking, sitting down +│ │ ├── CabinLayouts.* Per-truck cabin layouts +│ │ ├── CabinPresets.* Shipped layouts, one per truck model +│ │ ├── CabinWalkSounds.* Footsteps (FMOD bank) +│ │ └── InteriorCameraOverride.* Swaps the camera's limits while out of the seat │ └── ui/ │ ├── SettingsWindow.cpp / .hpp MotionCab Quick Settings window │ ├── EffectTabs.cpp / .hpp Driving/Road/Cabin/Manual tabs +│ ├── CabinLayoutPanel.cpp/.hpp Cabin Walk's layout panel (Advanced) │ ├── SettingsTab.cpp / .hpp Settings tab: keybind, profiles, reset │ ├── AboutTab.cpp / .hpp About tab │ ├── Widgets.cpp / .hpp Shared look and widgets │ └── OpenUrl.cpp / .hpp Opens a URL in the system browser ├── data/ -│ └── logo.png About tab logo +│ ├── logo.png About tab logo +│ └── sounds/ Cabin Walk's FMOD bank ├── localization/ │ └── .json UI text, one file per language ├── SPF_API/ SPF Framework SDK headers From 7adad5c753871f20798bd8de72ca8fe7e62ccd06 Mon Sep 17 00:00:00 2001 From: aefly <288435012+aefly@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:47:27 +0200 Subject: [PATCH 5/5] chore: ignore `TODO.md` --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1bed516..38038df 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ *.o *.obj CMakeUserPresets.json +TODO.md