/* ** m_haptics.cpp ** ** Haptic feedback implementation ** **--------------------------------------------------------------------------- ** ** Copyright 2025 Marcus Minhorst ** Copyright 2025 ZDoom + GZDoom teams, and contributors ** ** This program is free software: you can redistribute it and/or modify ** it under the terms of the GNU General Public License as published by ** the Free Software Foundation, either version 3 of the License, or ** (at your option) any later version. ** ** This program is distributed in the hope that it will be useful, ** but WITHOUT ANY WARRANTY; without even the implied warranty of ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ** GNU General Public License for more details. ** ** You should have received a copy of the GNU General Public License ** along with this program. If not, see http://www.gnu.org/licenses/ ** **--------------------------------------------------------------------------- ** */ // HEADER FILES ------------------------------------------------------------ #include #include "c_cvars.h" #include "doomstat.h" #include "m_haptics.h" #include "name.h" #include "printf.h" #include "s_soundinternal.h" #include "tarray.h" #include "zstring.h" // MACROS ------------------------------------------------------------------ #ifndef MAX_TRY_DEPTH #define MAX_TRY_DEPTH 8 #endif // TYPES ------------------------------------------------------------------- // EXTERNAL FUNCTION PROTOTYPES -------------------------------------------- // PUBLIC FUNCTION PROTOTYPES ---------------------------------------------- // PRIVATE FUNCTION PROTOTYPES --------------------------------------------- // EXTERNAL DATA DECLARATIONS ---------------------------------------------- extern bool AppActive; // PRIVATE DATA DEFINITIONS ------------------------------------------------ struct { int tic = gametic; // last tic processed bool dirty = false; // do we need to do something next tick ? bool enabled = true; // do we need to do anything ever ? bool active = true; // is the game currently not paused ? struct Haptics current = {0,0,0,0,0}; // current state of the controller TMap channels; // active rumbles (that will be mixed) } Haptics; TMap RumbleDefinition = {}; TMap RumbleMapping = {}; TMap RumbleAlias = {}; // fallback names. these exist in base sndinfo const FName HapticIntense = "INTENSE", HapticHeavy = "HEAVY", HapticMedium = "MEDIUM", HapticLight = "LIGHT", HapticSubtle = "SUBTLE"; // PUBLIC DATA DEFINITIONS ------------------------------------------------- CVARD(Bool, haptics_debug, false, CVAR_ARCHIVE | CVAR_GLOBALCONFIG, "print diagnostics for haptic feedback"); // CODE -------------------------------------------------------------------- //========================================================================== // // Joy_GetMapping // // Takes a sound name, and find its corresponding rumble identifier. // //========================================================================== const FName * Joy_GetMapping(const FName identifier) { const FName * mapping = RumbleMapping.CheckKey(identifier); if (!mapping && identifier != "") { Printf(DMSG_WARNING, "Unknown rumble mapping '%s'\n", identifier.GetChars()); } return mapping; } //========================================================================== // // Joy_GetRumble // // Takes a rumble identifier, and returns its haptics definition. // //========================================================================== const struct Haptics * Joy_GetRumble(FName identifier) { struct Haptics * rumble = RumbleDefinition.CheckKey(identifier); if (!rumble) { Printf(DMSG_ERROR, TEXTCOLOR_RED "Rumble mapping not found! '%s'\n", identifier.GetChars()); return nullptr; } return rumble; } //========================================================================== // // Joy_AddRumbleType // // Ties a rumble identifier to a haptics definition. // //========================================================================== void Joy_AddRumbleType(const FName identifier, const struct Haptics data) { RumbleDefinition.Insert(identifier, data); } //========================================================================== // // Joy_AddRumbleAlias // // Adds an alternative rumble identifier for a haptics definition. // Joy_ReadyRumbleMapping must be called after adding any aliases. // //========================================================================== void Joy_AddRumbleAlias(const FName alias, const FName actual) { RumbleAlias.Insert(alias, actual); } //========================================================================== // // Joy_MapRumbleType // // Ties a sound to a rumble identifier. // //========================================================================== void Joy_MapRumbleType(const FName sound, const FName identifier) { RumbleMapping.Insert(sound, identifier); } //========================================================================== // // Joy_ResetRumbleMapping // // Resets entire rumble state // //========================================================================== void Joy_ResetRumbleMapping() { RumbleMapping.Clear(); RumbleAlias.Clear(); RumbleDefinition.Clear(); Haptics.channels.Clear(); Haptics.current.high_frequency = Haptics.current.low_frequency = Haptics.current.left_trigger = Haptics.current.right_trigger = Haptics.current.ticks = 0; I_Rumble(0, 0, 0, 0); } //========================================================================== // // Joy_ReadyRumbleMapping // // Resets entire rumble state // //========================================================================== void Joy_ReadyRumbleMapping() { TArray found; TMapIterator it(RumbleAlias); TMap::Pair* pair; while (RumbleAlias.CountUsed()) { while (it.NextPair(pair)) { auto predefined = RumbleDefinition.CheckKey(pair->Key); if (predefined) { Printf(DMSG_ERROR, TEXTCOLOR_RED "Rumble alias trying to redefine mapping! '%s'\n", pair->Key.GetChars()); continue; } auto mapping = RumbleDefinition.CheckKey(pair->Value); if (!mapping) continue; found.AddUnique(pair->Key); RumbleDefinition.Insert(pair->Key, *mapping); } it.Reset(); if (found.Size() == 0) { FString list = "["; while (it.NextPair(pair)) list.AppendFormat(" '%s'->'%s'", pair->Key.GetChars(), pair->Value.GetChars()); Printf(DMSG_ERROR, TEXTCOLOR_RED "Circular rumble alias found! (%d) %s ]\n", RumbleAlias.CountUsed(), list.GetChars()); break; } while (found.size() > 0) { RumbleAlias.Remove(found.Last()); found.Pop(); } } } //========================================================================== // // Joy_RumbleTick // // Called once per gametic. Manages rumble state. // //========================================================================== void Joy_RumbleTick() { if (!Haptics.enabled) return; // pause detection if (AppActive != Haptics.active) { Haptics.active = AppActive; if (Haptics.active && Haptics.current.ticks != 0) { I_Rumble( Haptics.current.high_frequency, Haptics.current.low_frequency, Haptics.current.left_trigger, Haptics.current.right_trigger ); } else { I_Rumble(0, 0, 0, 0); } } if (Haptics.tic >= gametic) return; Haptics.tic = gametic; // new value OR time elapsed Haptics.dirty |= Haptics.current.ticks != 0 && Haptics.current.ticks < Haptics.tic; if (!Haptics.dirty) return; // init Haptics.current.ticks = 0; Haptics.current.high_frequency = 0; Haptics.current.low_frequency = 0; Haptics.current.left_trigger = 0; Haptics.current.right_trigger = 0; TMapIterator it(Haptics.channels); TMap::Pair* pair; TArray stale; // remove old rumble data while (it.NextPair(pair)) { if (pair->Value.ticks < Haptics.tic) { stale.Push(pair->Key); } } for (auto key: stale) { Haptics.channels.Remove(key); } it.Reset(); while (it.NextPair(pair)) { // grab upcoming event time Haptics.current.ticks = Haptics.current.ticks == 0 ? pair->Value.ticks : std::min(Haptics.current.ticks, pair->Value.ticks); // simple intensity mixing Haptics.current.high_frequency += pair->Value.high_frequency; Haptics.current.low_frequency += pair->Value.low_frequency; Haptics.current.left_trigger += pair->Value.left_trigger; Haptics.current.right_trigger += pair->Value.right_trigger; } // should this be clamped to [0,1]? Maybe a controller api will support "hdr" haptics lol // Haptics.current.high_frequency = std::min(std::max(0.0, Haptics.current.high_frequency), 1.0); // Haptics.current.low_frequency = std::min(std::max(0.0, Haptics.current.low_frequency), 1.0); // Haptics.current.left_trigger = std::min(std::max(0.0, Haptics.current.left_trigger), 1.0); // Haptics.current.right_trigger = std::min(std::max(0.0, Haptics.current.right_trigger), 1.0); I_Rumble( Haptics.current.high_frequency, Haptics.current.low_frequency, Haptics.current.left_trigger, Haptics.current.right_trigger ); Haptics.dirty = false; } //========================================================================== // // Joy_Rumble // // Requests haptic feedback. Attenuation of >=1 results in no-op. // //========================================================================== void Joy_Rumble(const FName source, const struct Haptics data, double attenuation) { if (!Haptics.enabled) return; if (data.ticks <= 0) return; if (attenuation >= 1) return; float strength = 1 - (attenuation < 0? 0: attenuation); if (haptics_debug) Printf("r %s * %g\n", source.GetChars(), strength); // this will overwrite stuff from same source mapping (weapons/pistol not W_BULLET) Haptics.channels.Insert(source, { Haptics.tic + data.ticks + 1, data.high_frequency * strength, data.low_frequency * strength, data.left_trigger * strength, data.right_trigger * strength, }); Haptics.dirty = true; } //========================================================================== // // Joy_Rumble // // Requests haptic feedback. Attenuation of >=1 results in no-op. // //========================================================================== void Joy_Rumble(const FName identifier, double attenuation) { const FName * mapping = Joy_GetMapping(identifier); if (mapping == nullptr) return; const struct Haptics * rumble = Joy_GetRumble(*mapping); if (rumble == nullptr) return; Joy_Rumble(identifier, * rumble, attenuation); }