diff --git a/addons/medical/XEH_PREP.hpp b/addons/medical/XEH_PREP.hpp index 89cf239c94c..95d38ccce06 100644 --- a/addons/medical/XEH_PREP.hpp +++ b/addons/medical/XEH_PREP.hpp @@ -12,3 +12,6 @@ PREP(isInStableCondition); PREP(isInjured); PREP(serializeState); PREP(setUnconscious); +PREP(sortStatement_armor); +PREP(statBarStatement_armor); +PREP(statTextStatement_armor); diff --git a/addons/medical/arsenal/ACE_Arsenal_Sorts.hpp b/addons/medical/arsenal/ACE_Arsenal_Sorts.hpp new file mode 100644 index 00000000000..0c3023e1765 --- /dev/null +++ b/addons/medical/arsenal/ACE_Arsenal_Sorts.hpp @@ -0,0 +1,14 @@ +class EGVAR(arsenal,sorts) { + class sortBase; + class ACE_protectionBallistic: sortBase { + condition = QUOTE(!(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)])); // Hide if medical is enabled + }; + class ACE_protectionExplosive: ACE_protectionBallistic { + condition = QUOTE(!(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)])); // Hide if medical is enabled + }; + class GVAR(armor): ACE_protectionBallistic { + displayName = CSTRING(sortByArmorText); + condition = QUOTE(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)]); // Show if medical is enabled + statement = QUOTE(call FUNC(sortStatement_armor)); + }; +}; diff --git a/addons/medical/arsenal/ACE_Arsenal_Stats.hpp b/addons/medical/arsenal/ACE_Arsenal_Stats.hpp new file mode 100644 index 00000000000..740756e3e16 --- /dev/null +++ b/addons/medical/arsenal/ACE_Arsenal_Stats.hpp @@ -0,0 +1,17 @@ +class EGVAR(arsenal,stats) { + class statBase; + class ACE_ballisticProtection: statBase { + condition = QUOTE(!(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)])); // Hide if medical is enabled + }; + class ACE_explosiveResistance: statBase { + condition = QUOTE(!(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)])); // Hide if medical is enabled + }; + class GVAR(armor): ACE_ballisticProtection { + displayName = "$STR_UI_ABAR"; + condition = QUOTE(missionNamespace getVariable [ARR_2(QQEGVAR(medical,enabled),false)]); // Show if medical is enabled + showBar = 1; + barStatement = QUOTE((_this select 1) call FUNC(statBarStatement_armor)); + showText = 1; + textStatement = QUOTE((_this select 1) call FUNC(statTextStatement_armor)); + }; +}; diff --git a/addons/medical/arsenal/config.cpp b/addons/medical/arsenal/config.cpp new file mode 100644 index 00000000000..169afce926a --- /dev/null +++ b/addons/medical/arsenal/config.cpp @@ -0,0 +1,19 @@ +#include "script_component.hpp" + +class CfgPatches { + class SUBADDON { + addonRootClass = QUOTE(COMPONENT); + units[] = {}; + weapons[] = {}; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "ace_medical_engine", + "ace_arsenal" + }; + skipWhenMissingDependencies = 1; + VERSION_CONFIG; + }; +}; + +#include "ACE_Arsenal_Stats.hpp" +#include "ACE_Arsenal_Sorts.hpp" diff --git a/addons/medical/arsenal/script_component.hpp b/addons/medical/arsenal/script_component.hpp new file mode 100644 index 00000000000..9de14c499cf --- /dev/null +++ b/addons/medical/arsenal/script_component.hpp @@ -0,0 +1,3 @@ +#define SUBCOMPONENT arsenal +#define SUBCOMPONENT_BEAUTIFIED Arsenal +#include "..\script_component.hpp" diff --git a/addons/medical/functions/fnc_sortStatement_armor.sqf b/addons/medical/functions/fnc_sortStatement_armor.sqf new file mode 100644 index 00000000000..96af008a338 --- /dev/null +++ b/addons/medical/functions/fnc_sortStatement_armor.sqf @@ -0,0 +1,23 @@ +#include "..\script_component.hpp" +/* + * Author: LinkIsGrim + * Statement to sort items by their armor level. + * + * Arguments: + * 0: Item Config + * + * Return Value: + * Sorting Value + * + * Public: No +*/ + +params ["_config"]; + +// Same classification the armor penetration model and the armor stat use +private _itemType = getNumber (_config >> "ItemInfo" >> "type"); +private _hitpoint = ["HitChest", "HitHead"] select (_itemType == TYPE_HEADGEAR); + +([configName _config, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_armorLevel"]; + +_armorLevel diff --git a/addons/medical/functions/fnc_statBarStatement_armor.sqf b/addons/medical/functions/fnc_statBarStatement_armor.sqf new file mode 100644 index 00000000000..6a4c6da4f4b --- /dev/null +++ b/addons/medical/functions/fnc_statBarStatement_armor.sqf @@ -0,0 +1,29 @@ +#include "..\script_component.hpp" +/* + * Author: LinkIsGrim + * Bar statement for armor. + * + * Arguments: + * 0: Item config path + * + * Return Value: + * Bar statement + * + * Public: No +*/ + +params ["_config"]; + +// Use the same classification the armor penetration model uses, so the arsenal can't disagree +// with what actually happens when the wearer gets shot +private _itemType = getNumber (_config >> "ItemInfo" >> "type"); +private _isHeadgear = _itemType == TYPE_HEADGEAR; + +private _hitpoint = ["HitChest", "HitHead"] select _isHeadgear; +// Ceilings are equal today, but headgear is rated against different threats than plates, so keep +// the bar normalised per type in case they diverge again +private _maxLevel = [MAX_PLATE_LEVEL, MAX_HELMET_LEVEL] select _isHeadgear; + +([configName _config, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_armorLevel"]; + +0.01 max (_armorLevel / _maxLevel) min 1 diff --git a/addons/medical/functions/fnc_statTextStatement_armor.sqf b/addons/medical/functions/fnc_statTextStatement_armor.sqf new file mode 100644 index 00000000000..d5eac1c6900 --- /dev/null +++ b/addons/medical/functions/fnc_statTextStatement_armor.sqf @@ -0,0 +1,34 @@ +#include "..\script_component.hpp" +/* + * Author: LinkIsGrim + * Text statement for armor. + * + * Arguments: + * 0: Item config path + * + * Return Value: + * Stat Text + * + * Public: No +*/ + +params ["_config"]; + +// Use the same classification the armor penetration model uses, so the arsenal can't disagree +// with what actually happens when the wearer gets shot +private _itemType = getNumber (_config >> "ItemInfo" >> "type"); +private _hitpoint = ["HitChest", "HitHead"] select (_itemType == TYPE_HEADGEAR); + +([configName _config, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_armorLevel"]; + +// Levels are continuous (a blend of the armor and passThrough readings), round for display +_armorLevel = 0 max (round _armorLevel) min MAX_PLATE_LEVEL; + +localize ([ + "STR_A3_SP_NOARMOR", + "STR_A3_SP_AL_I", + "STR_A3_SP_AL_II", + "STR_A3_SP_AL_III", + "STR_A3_SP_AL_IV", + "STR_A3_SP_AL_V" +] select _armorLevel) diff --git a/addons/medical/stringtable.xml b/addons/medical/stringtable.xml index 4f40b153636..7b7e5ccc030 100644 --- a/addons/medical/stringtable.xml +++ b/addons/medical/stringtable.xml @@ -466,6 +466,9 @@ Játékosok és AI Гравців і ботів + + Sort by armor level + When an unconscious patient has Epinephrine in their system, the time between spontaneous wake up checks is divided by this value. Zvyšuje jak často je proveden test na probuzení z bezvědomí pokud má pacient Epinefrin ve svém krevním oběhu. diff --git a/addons/medical_damage/XEH_preInit.sqf b/addons/medical_damage/XEH_preInit.sqf index 36558eb1666..7f957d67c11 100644 --- a/addons/medical_damage/XEH_preInit.sqf +++ b/addons/medical_damage/XEH_preInit.sqf @@ -13,6 +13,9 @@ call FUNC(parseConfigForInjuries); // Used for armor penetration calculation GVAR(ammoCache) = createHashMap; +// Resolved armor thickness/passThrough per uniform+vest+headgear+hitpoint combination +GVAR(armorLayerCache) = createHashMap; + /* addMissionEventHandler ["Loaded",{ INFO("Mission Loaded - Reloading medical configs for extension"); diff --git a/addons/medical_damage/functions/fnc_getAmmoData.sqf b/addons/medical_damage/functions/fnc_getAmmoData.sqf index 8254fa08abf..d214df88370 100644 --- a/addons/medical_damage/functions/fnc_getAmmoData.sqf +++ b/addons/medical_damage/functions/fnc_getAmmoData.sqf @@ -1,13 +1,17 @@ #include "..\script_component.hpp" /* * Author: LinkIsGrim - * Returns base damage value, penetration factor, and expected muzzle velocity of a given round, either from a cache or by reading the ammo config. + * Returns base damage value, penetration multiplier, expected muzzle velocity and explosive fraction + * of a given round, either from a cache or by reading the ammo config. * * Arguments: * 0: Ammo * * Return Value: - * Base damage value, penetration factor, muzzle velocity + * 0: Base damage value + * 1: Penetration factor, mm RHA per m/s (caliber * penetrability) + * 2: Muzzle velocity + * 3: Fraction of damage that is explosive rather than kinetic, 0..1 * * Example: * "B_556x45_Ball" call ace_medical_damage_fnc_getAmmoData @@ -15,7 +19,8 @@ * Public: No */ -// Baseline penetrability used for armor penetration calculation, see (https://community.bistudio.com/wiki/CfgAmmo_Config_Reference#caliber) +// Penetration depth in mm is velocity * caliber * penetrability / 1000, RHA penetrability is 15 +// ref https://community.bistudio.com/wiki/CfgAmmo_Config_Reference#caliber #define ARMOR_PENETRABILITY 0.015 params ["_ammo"]; @@ -25,12 +30,13 @@ GVAR(ammoCache) getOrDefaultCall [toLowerANSI _ammo, { private _ammoConfig = configFile >> "CfgAmmo" >> _ammo; if (isNull _ammoConfig) then { - [0, 0, 0] // return + [0, 0, 0, 0] // return } else { private _hit = getNumber (_ammoConfig >> "hit"); - private _caliber = getNumber (_ammoConfig >> "caliber"); + private _penFactor = getNumber (_ammoConfig >> "caliber") * ARMOR_PENETRABILITY; private _typicalSpeed = getNumber (_ammoConfig >> "typicalSpeed"); - private _penFactor = _caliber * ARMOR_PENETRABILITY; - [_hit, _penFactor, _typicalSpeed] // return + // Some vanilla ammo goes above 1 (B_20mm is 1.8), clamp so callers can rely on the range + private _explosive = 0 max getNumber (_ammoConfig >> "explosive") min 1; + [_hit, _penFactor, _typicalSpeed, _explosive] // return }; }, true] diff --git a/addons/medical_damage/functions/fnc_woundsHandlerArmorPenetration.sqf b/addons/medical_damage/functions/fnc_woundsHandlerArmorPenetration.sqf index c679a80fd77..e4c6b4278ba 100644 --- a/addons/medical_damage/functions/fnc_woundsHandlerArmorPenetration.sqf +++ b/addons/medical_damage/functions/fnc_woundsHandlerArmorPenetration.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* * Author: LinkIsGrim - * Custom wounds handler for armor penetration. Calculates damage based on round material penetration and unit armor + * Custom wounds handler for armor penetration. Calculates damage based on round penetration and armor plate level. * * Arguments: * 0: Unit that was hit @@ -9,6 +9,7 @@ * 0: Engine damage * 1: Body part * 2: Real damage + * 3: Engine hitpoint * 2: Type of the damage done * 3: Ammo * @@ -16,66 +17,161 @@ * None * * Example: - * [player, [[0.5, "Body", 1]], "bullet"] call ace_medical_damage_fnc_woundsHandlerArmorPenetration + * [player, [[0.5, "Body", 1, "HitChest"]], "bullet"] call ace_medical_damage_fnc_woundsHandlerArmorPenetration * * Public: No */ -// This gets close to vanilla values on FMJ ammo +// Maps ammo "hit" onto the wound damage scale used by ACE_Medical_Injuries, hit/10 puts +// a pistol round around a minor wound and .50 BMG at an avulsion #define DAMAGE_SCALING_FACTOR 10 -#define UNSCALED_BASE_ARMOR 2 +// The head tolerates far less energy than the torso +#define HEAD_DAMAGE_MULTIPLIER 1.5 +// Behind armor blunt trauma must stay below PENETRATION_THRESHOLD, a defeated round never penetrates +#define MAX_BABT_DAMAGE 0.34 +// How much a layer behind the outermost hard one is worth. Layered armor is less effective than a +// single plate of the same total thickness, but vanilla does stack these additively, and an armored +// suit under a heavy plate carrier is meant to be absurd - at this value it beats tungsten AP and +// still loses to anti-materiel calibers +#define ARMOR_LAYER_EFFICIENCY 0.5 +// Spread of the ballistic limit. Armor defeats rounds over a velocity band rather than at a hard +// cutoff, and this also stands in for the two things we can't derive from a HandleDamage event: +// impact obliquity, which lengthens the path through the plate by 1/cos and so only ever raises +// the limit (+41% at 45 degrees), and plate coverage, where edges, gaps and soft-panel-only areas +// leave less protection than the nominal level. Hence the asymmetry +#define BALLISTIC_LIMIT_COVERAGE 0.25 +#define BALLISTIC_LIMIT_OBLIQUITY 0.45 if (!EGVAR(medical,alternateArmorPenetration)) exitWith {_this}; params ["_unit", "_allDamages", "_typeOfDamage", "_ammo"]; TRACE_4("woundsHandlerArmorPenetration",_unit,_allDamages,_typeOfDamage,_ammo); -// See (https://community.bistudio.com/wiki/CfgAmmo_Config_Reference#caliber), -// _penFactor is ammo "caliber" * RHA penetrability, armor plates according to BI are just made of RHAe material -(_ammo call FUNC(getAmmoData)) params ["_hit", "_penFactor", "_typicalSpeed"]; +(_ammo call FUNC(getAmmoData)) params ["_hit", "_penFactor", "_typicalSpeed", "_explosive"]; -// Skip bad ammo -if (_hit <= 0) exitWith {_this}; +// Skip ammo we can't reason about. At explosive >= 1 no part of hit is scaled by speed, +// so impact velocity can't be recovered from the damage at all +if (_hit <= 0 || {_penFactor <= 0} || {_typicalSpeed <= 0} || {_explosive >= 1}) exitWith { + TRACE_4("skipping unusable ammo",_hit,_penFactor,_typicalSpeed,_explosive); + _this // return +}; + +// Selection specific, but #structural can outrank every body part on high damage rounds and the +// base handler ignores it and wounds the next part down, so target the entry it will actually use +private _damageIndex = _allDamages findIf {(_x select 1) != "#structural"}; + +if (_damageIndex < 0) exitWith { + TRACE_1("no body part damaged",_allDamages); + _this // return +}; -private _damageData = _allDamages select 0; // selection specific -_damageData params ["_engineDamage", "_bodyPart", "_realDamage"]; +private _damageData = _allDamages select _damageIndex; +_damageData params ["", "_bodyPart", "_realDamage", ["_hitpoint", ""]]; +if (_hitpoint isEqualTo "") exitWith { + TRACE_1("no engine hitpoint",_allDamages); + _this // return +}; -private _armorLevelStep = [2, 4] select (_bodyPart == "body"); -private _armor = (_realDamage/_engineDamage) - UNSCALED_BASE_ARMOR; // remove base armor +// Engine damage is hit * (speed/typicalSpeed), and only the (1 - explosive) portion is scaled by speed, +// so this recovers the true impact speed. Damage is already lowered by the engine for hit angle +// ref https://community.bistudio.com/wiki/CfgAmmo_Config_Reference#hit +private _impactSpeed = (((_realDamage/_hit) - _explosive) / (1 - _explosive)) * _typicalSpeed; -// There's no need to calculate penetration if there is no armor to begin with, base damage handling is good enough in this case -if (_armor <= _armorLevelStep) exitWith { - TRACE_3("skipping no armor",_armor,_bodyPart,_armorLevelStep); +if (_impactSpeed <= 0) exitWith { + TRACE_2("no impact speed",_impactSpeed,_realDamage); _this // return }; -// Cap at Armor Level V -// Jumping from no armor to armor level 1 is 2 steps -private _armorLevel = 0 max (round ((_armor - (_armorLevelStep * 2)) / _armorLevelStep)) min 4; -TRACE_3("gotArmorLevel",_armorLevel,_armor,_armorLevelStep); - -// Armor RHA equivalent, non-linear, ref \a3\Data_F\Penetration\armour_plate/thin/medium/heavy.bisurf -// Divided by 2 to keep inline with vanilla caliber values -private _armorThickness = [ - 6, - 15, - 21, - 40, - 55 -] select _armorLevel; -TRACE_1("gotArmorThickness",_armorThickness); - -// Impact damage is hit * (impactSpeed / typicalSpeed): https://community.bistudio.com/wiki/CfgAmmo_Config_Reference#typicalSpeed -// Impact damage is already lowered by engine based on hit angle, so speed and therefore penetration are also naturally lowered -// Assume typicalSpeed < 1 means no damage dropoff -private _impactSpeed = (_realDamage/_hit) * (_typicalSpeed max 1); - -private _penDepth = _penFactor * _impactSpeed; - -// Max damage is the config value, go down from there based on armor penetration -private _finalDamage = (_hit * ((_penDepth/_armorThickness) min 1)) / DAMAGE_SCALING_FACTOR; +// RHA equivalent thickness in mm per level. +// Torso levels are anchored to the velocity at which their NIJ test round is barely defeated: +// I -> NIJ IIA (9mm @ 373) +// II -> NIJ IIIA (.44 Magnum @ 436) +// III -> no NIJ equivalent, see below +// IV -> NIJ III (7.62x51 M80 @ 847) +// V -> NIJ IV (.30-06 M2 AP @ 878) +// Level III is deliberately unanchored. Its own IIIA value is pistol-only, which left the Carrier +// Lite performing like a TacVest against every rifle round in the game, so it sits at a "special +// threat" tier instead: stops 5.56, coin-flips 7.62x39, never stops M80. That vacated the IIIA +// anchor, which level II then took - otherwise I and II sat 1.1mm apart and integrated soft armor +// (CSAT fatigues) was worth almost nothing layered under a vest. NIJ II has no rung as a result. +// Helmets need their own anchors, one Ops-Core model per level, using their published V0 +// resistance to penetration figures run through the same formula: +// I FAST SF (9mm @ 364), II FAST XP/LE (9mm @ 427), III TBH-IIIA (7.62x25 @ 450), +// IV RF1 (7.62x39 MSC @ 725), V RF2 (7.62x51 M80 @ 847). +// Only the last two are rifle rated, everything below is fragmentation and pistol only +private _uniform = uniform _unit; +private _vest = vest _unit; +private _headgear = headgear _unit; + +// The whole layer stack collapses to one thickness/passThrough pair that only changes when the +// gear or the hitpoint does, so resolve it once per combination rather than on every hit +(GVAR(armorLayerCache) getOrDefaultCall [[_uniform, _vest, _headgear, _hitpoint] joinString "$", { + TRACE_1("Armor layer cache miss",_this); + private _torsoPoints = [0, 6.7, 9.2, 12.5, 20.4, 26.4]; + private _helmetPoints = [0, 6.6, 7.7, 9.2, 13.5, 20.4]; + + private _fnc_levelToThickness = { + params ["_level", "_points"]; + private _lowerLevel = floor _level; + private _levelThickness = _points select _lowerLevel; + + // Levels are continuous, interpolate between the anchors + if (_lowerLevel < MAX_PLATE_LEVEL) then { + _levelThickness = _levelThickness + (_level - _lowerLevel) * ((_points select (_lowerLevel + 1)) - _levelThickness); + }; + + _levelThickness // return + }; + + // Uniforms are on the same ladder as vests (CSAT fatigues are level II, Viper suits level III), + // headgear gets its own table since helmets are rated against different threats + ([_uniform, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_uniformLevel", "_uniformPassThrough"]; + ([_vest, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_vestLevel", "_vestPassThrough"]; + ([_headgear, _hitpoint] call EFUNC(medical_engine,getItemPlate)) params ["_headLevel", "_headPassThrough"]; + + private _layers = [ + [[_uniformLevel, _torsoPoints] call _fnc_levelToThickness, _uniformPassThrough], + [[_vestLevel, _torsoPoints] call _fnc_levelToThickness, _vestPassThrough], + [[_headLevel, _helmetPoints] call _fnc_levelToThickness, _headPassThrough] + ]; + + _layers sort false; + + // Layers stack, but nowhere near additively - the outermost hard layer does almost all the work + // and what sits behind it only catches the residual. Anything else would let a plate carrier over + // an armored suit sum its way past the top of the ladder + (_layers select 0) params ["_bestThickness", "_bestPassThrough"]; + + [ + _bestThickness + ARMOR_LAYER_EFFICIENCY * ((_layers select 1 select 0) + (_layers select 2 select 0)), + _bestPassThrough + ] // return +}, true]) params ["_thickness", "_passThrough"]; + +// Speed at which this round's penetration exactly equals the plate. +// Real armor has a zone of mixed results rather than a hard cutoff - the limit is a V50, where +// half the rounds get through - so vary it a little to avoid an unnaturally sharp step +private _ballisticLimit = (_thickness / _penFactor) * random [1 - BALLISTIC_LIMIT_COVERAGE, 1, 1 + BALLISTIC_LIMIT_OBLIQUITY]; + +// Recht-Ipson residual velocity, zero when the plate defeats the round +private _residualSpeed = sqrt (0 max (_impactSpeed^2 - _ballisticLimit^2)); + +private _damageMultiplier = [1, HEAD_DAMAGE_MULTIPLIER] select (_bodyPart == "head"); + +// Feeding residual speed back through the engine's own damage formula keeps this on the same +// scale as an unarmored hit, so no plate means no discontinuity +private _penetratingDamage = (_hit/DAMAGE_SCALING_FACTOR) * (_residualSpeed/_typicalSpeed) * _damageMultiplier; + +// Behind armor blunt trauma - the speed the plate bled off, scaled by how much of it reaches the +// wearer. Using the absorbed fraction rather than a flat ratio keeps this proportional to how hard +// the round actually hit, and makes the two branches complementary: a fully defeated round puts its +// whole damage budget here, a clean pass-through puts none of it. +// Capped so it always reads as a contusion rather than a penetrating wound +private _babtDamage = ((_hit/DAMAGE_SCALING_FACTOR) * ((_impactSpeed - _residualSpeed)/_typicalSpeed) * _passThrough * _damageMultiplier) min MAX_BABT_DAMAGE; + +private _finalDamage = _penetratingDamage max _babtDamage; _damageData set [0, _finalDamage]; -TRACE_3("Armor penetration handled, passing damage",_finalDamage,_damageData,_allDamages); +TRACE_5("Armor penetration handled",_thickness,_impactSpeed,_ballisticLimit,_finalDamage,_allDamages); _this // return diff --git a/addons/medical_damage/functions/fnc_woundsHandlerBase.sqf b/addons/medical_damage/functions/fnc_woundsHandlerBase.sqf index 402018353aa..216a7c3dbab 100644 --- a/addons/medical_damage/functions/fnc_woundsHandlerBase.sqf +++ b/addons/medical_damage/functions/fnc_woundsHandlerBase.sqf @@ -12,7 +12,7 @@ * None * * Example: - * [player, [[0.5, "Body", 1]], "bullet"] call ace_medical_damage_fnc_woundsHandlerBase + * [player, [[0.5, "Body", 1, "HitChest"]], "bullet"] call ace_medical_damage_fnc_woundsHandlerBase * * Public: No */ @@ -92,14 +92,17 @@ private _bodyPartVisParams = [_unit, false, false, false, false]; // params arra _bodyPartDamage set [_bodyPartNToAdd, (_bodyPartDamage select _bodyPartNToAdd) + _woundDamage]; _bodyPartVisParams set [[1,2,3,3,4,4] select _bodyPartNToAdd, true]; // Mark the body part index needs updating - // Anything above this value is guaranteed worst wound possible - private _worstDamage = 2; + // Anything above this value is guaranteed worst wound possible, anything below is the smallest + // Bounds are tight because damage already scales with the round's "hit" value - widening them + // saturates the clamp and makes a 9mm and a .50 BMG produce the same size wound + private _worstDamage = 1.71; + private _leastDamage = 0.415; #define LARGE_WOUND_THRESHOLD 0.5 // Config specifies bleeding and pain for worst possible wound // Worse wound correlates to higher damage, damage is not capped at 1 - private _woundSize = linearConversion [0.1, _worstDamage, _woundDamage * _sizeMultiplier, LARGE_WOUND_THRESHOLD^3, 1, true]; + private _woundSize = linearConversion [_leastDamage, _worstDamage, _woundDamage * _sizeMultiplier, LARGE_WOUND_THRESHOLD^3, 1, true]; private _pain = _woundSize * _painMultiplier * _injuryPain; _painLevel = _painLevel + _pain; diff --git a/addons/medical_damage/functions/fnc_woundsHandlerBurning.sqf b/addons/medical_damage/functions/fnc_woundsHandlerBurning.sqf index 1130c8d3f53..3b5a399439c 100644 --- a/addons/medical_damage/functions/fnc_woundsHandlerBurning.sqf +++ b/addons/medical_damage/functions/fnc_woundsHandlerBurning.sqf @@ -22,7 +22,7 @@ TRACE_3("woundsHandlerBurning",_unit,_allDamages,_typeOfDamage); #define FIRE_DAMAGE_INTERVAL 1 { - _x params ["_damage", "_bodyPart"]; + _x params ["_damage", "_bodyPart", "", "_hitpoint"]; if (_bodyPart != "#structural") then { continue @@ -37,9 +37,9 @@ TRACE_3("woundsHandlerBurning",_unit,_allDamages,_typeOfDamage); [{ params ["_unit"]; - private _bodyPart = selectRandom ["body", "leftleg", "rightleg"]; + private _bodyPart = selectRandom [["body", "HitChest"], ["leftleg", "HitLeftLeg"], ["rightleg", "HitRightLeg"]]; private _storedDamage = _unit getVariable [QGVAR(storedBurnDamage), 0]; - [QEGVAR(medical,woundReceived), [_unit, [[_storedDamage, _bodyPart, _storedDamage]], _unit, "burn"]] call CBA_fnc_localEvent; + [QEGVAR(medical,woundReceived), [_unit, [[_storedDamage, _bodyPart select 0, _storedDamage, _bodyPart select 1]], _unit, "burn"]] call CBA_fnc_localEvent; _unit setVariable [QGVAR(storedBurnDamage), 0, true]; }, [_unit], FIRE_DAMAGE_INTERVAL] call CBA_fnc_waitAndExecute; diff --git a/addons/medical_damage/functions/fnc_woundsHandlerVehiclecrash.sqf b/addons/medical_damage/functions/fnc_woundsHandlerVehiclecrash.sqf index 978eea4752e..3949b9b2e36 100644 --- a/addons/medical_damage/functions/fnc_woundsHandlerVehiclecrash.sqf +++ b/addons/medical_damage/functions/fnc_woundsHandlerVehiclecrash.sqf @@ -21,7 +21,7 @@ TRACE_3("woundsHandlerVehicleCrash",_unit,_allDamages,_typeOfDamage); // randomise all hit selections private _newDamages = _allDamages apply { - [_x select 0, selectRandom ALL_BODY_PARTS, _x select 2]; + [_x select 0, selectRandom ALL_BODY_PARTS, _x select 2, ""]; }; TRACE_1("Vehicle crash handled, passing damage",_newDamages); diff --git a/addons/medical_damage/functions/fnc_woundsHandlerVehiclehit.sqf b/addons/medical_damage/functions/fnc_woundsHandlerVehiclehit.sqf index 248afb7ef06..b66af26dc3e 100644 --- a/addons/medical_damage/functions/fnc_woundsHandlerVehiclehit.sqf +++ b/addons/medical_damage/functions/fnc_woundsHandlerVehiclehit.sqf @@ -39,7 +39,7 @@ for "_i" from 1 to (_damageToApply * 6) do { private _newDamages = []; { - _newDamages pushBack [_damageMap get _x, _x, _damageToApply]; + _newDamages pushBack [_damageMap get _x, _x, _damageToApply, ""]; } forEach (keys _damageMap); // micro-optimization again, two 'get's is still faster than iterating over a hashmap TRACE_1("Vehicle explosion handled, passing damage",_newDamages); diff --git a/addons/medical_engine/XEH_PREP.hpp b/addons/medical_engine/XEH_PREP.hpp index 97147c60ff0..5af9ab26324 100644 --- a/addons/medical_engine/XEH_PREP.hpp +++ b/addons/medical_engine/XEH_PREP.hpp @@ -4,6 +4,7 @@ PREP(damageBodyPart); PREP(disableThirdParty); PREP(getHitpointArmor); PREP(getItemArmor); +PREP(getItemPlate); PREP(handleDamage); PREP(lockUnconsciousSeat); PREP(setStructuralDamage); diff --git a/addons/medical_engine/XEH_preInit.sqf b/addons/medical_engine/XEH_preInit.sqf index 2fc739665c2..8e5e79fac44 100644 --- a/addons/medical_engine/XEH_preInit.sqf +++ b/addons/medical_engine/XEH_preInit.sqf @@ -36,6 +36,12 @@ if (isNil QUOTE(FATAL_SUM_DAMAGE_WEIBULL_K) || isNil QUOTE(FATAL_SUM_DAMAGE_WEIB // Cache for armor values of equipped items (vests etc) GVAR(armorCache) = createHashMap; +// Cache for armor plate level/passThrough of equipped items, used for armor penetration +GVAR(plateCache) = createHashMap; + +// Cache for hitpoint sphere radii per unit type, used to resolve overlapping hitpoints on a hit +GVAR(hitpointRadiusCache) = createHashMap; + // Hack for #3168 (units in static weapons do not take any damage): // Doing a manual pre-load with a small distance seems to fix the LOD problems // with handle damage not returning full results. diff --git a/addons/medical_engine/functions/fnc_getItemPlate.sqf b/addons/medical_engine/functions/fnc_getItemPlate.sqf new file mode 100644 index 00000000000..398cdd60cb9 --- /dev/null +++ b/addons/medical_engine/functions/fnc_getItemPlate.sqf @@ -0,0 +1,71 @@ +#include "..\script_component.hpp" +/* + * Author: LinkIsGrim + * Returns the armor plate level and passThrough an item provides to a hitpoint. + * + * Arguments: + * 0: Item Class + * 1: Hitpoint + * + * Return Value: + * 0: Plate level, 0 (uncovered) to 5 + * 1: Fraction of damage passed through to the wearer + * + * Example: + * ["V_PlateCarrier_rgr", "HitChest"] call ace_medical_engine_fnc_getItemPlate + * + * Public: No + */ + +params ["_item", "_hitpoint"]; + +GVAR(plateCache) getOrDefaultCall [_this joinString "$", { + TRACE_2("Cache miss",_item,_hitpoint); + private _level = 0; + private _passThrough = 1; + + if !("" in [_item, _hitpoint]) then { + private _itemInfo = configFile >> "CfgWeapons" >> _item >> "ItemInfo"; + private _itemType = getNumber (_itemInfo >> "type"); + + private _armor = -1; + + if (_itemType == TYPE_UNIFORM) then { + // Uniform armor lives on the CfgVehicles class it spawns, and its hitpoint armor is a + // coefficient of the unit's base armor rather than an absolute value like a vest's. + // Multiplied out it lands on the same ladder - CSAT fatigues are II, Viper suits III + private _unitCfg = configFile >> "CfgVehicles" >> getText (_itemInfo >> "uniformClass"); + private _entry = _unitCfg >> "HitPoints" >> _hitpoint; + + if (isClass _entry) then { + _armor = getNumber (_unitCfg >> "armor") * (1 max getNumber (_entry >> "armor")); + _passThrough = 0 max getNumber (_entry >> "passThrough") min 1; + }; + } else { + private _entry = configProperties [_itemInfo >> "HitpointsProtectionInfo", "getText (_x >> 'hitpointName') == _hitpoint"] param [0, configNull]; + + if (!isNull _entry) then { + _armor = getNumber (_entry >> "armor"); + _passThrough = 0 max getNumber (_entry >> "passThrough") min 1; + }; + }; + + if (_armor >= 0) then { + private _isHeadgear = _itemType == TYPE_HEADGEAR; + private _maxLevel = [MAX_PLATE_LEVEL, MAX_HELMET_LEVEL] select _isHeadgear; + private _step = [ARMOR_LEVEL_STEP_VEST, ARMOR_LEVEL_STEP_HEADGEAR] select _isHeadgear; + + // Values above the top of the ladder aren't "better than level V", they're bad data - clamp instead of scaling + _level = 0 max (_armor/_step - 1) min _maxLevel; + + // Every vanilla helmet is passThrough 0.5, so it carries no level information for headgear + if (!_isHeadgear) then { + private _levelFromPassThrough = 0 max ((ARMOR_LEVEL_PASSTHROUGH_BASE - _passThrough) / ARMOR_LEVEL_PASSTHROUGH_STEP) min _maxLevel; + _level = _level * ARMOR_LEVEL_ARMOR_WEIGHT + _levelFromPassThrough * (1 - ARMOR_LEVEL_ARMOR_WEIGHT); + }; + }; + }; + + TRACE_4("gotPlate",_item,_hitpoint,_level,_passThrough); + [_level, _passThrough] // return +}, true] diff --git a/addons/medical_engine/functions/fnc_handleDamage.sqf b/addons/medical_engine/functions/fnc_handleDamage.sqf index ac84f4511e7..121fdbe091d 100644 --- a/addons/medical_engine/functions/fnc_handleDamage.sqf +++ b/addons/medical_engine/functions/fnc_handleDamage.sqf @@ -75,7 +75,7 @@ if ( {_damage isEqualTo (_oldDamage + 0.005)} ) exitWith { TRACE_5("Drowning",_unit,_shooter,_instigator,_damage,_newDamage); - [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, "Body", _newDamage]], _unit, "drowning"]] call CBA_fnc_localEvent; + [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, "Body", _newDamage, "HitChest"]], _unit, "drowning"]] call CBA_fnc_localEvent; 0 }; @@ -94,7 +94,7 @@ if ( // todo: no way to detect if stationary and another vehicle hits you ) exitWith { TRACE_5("Crash",_unit,_shooter,_instigator,_damage,_newDamage); - [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, _hitPoint, _newDamage]], _unit, "vehiclecrash"]] call CBA_fnc_localEvent; + [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, _hitPoint, _newDamage, _hitpoint]], _unit, "vehiclecrash"]] call CBA_fnc_localEvent; 0 }; @@ -114,7 +114,7 @@ if ( _unit setVariable [QEGVAR(medical,lastDamageSource), _shooter]; _unit setVariable [QEGVAR(medical,lastInstigator), _instigator]; - [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, _hitPoint, _newDamage]], _shooter, "vehiclehit"]] call CBA_fnc_localEvent; + [QEGVAR(medical,woundReceived), [_unit, [[_newDamage, _hitPoint, _newDamage, _hitpoint]], _shooter, "vehiclehit"]] call CBA_fnc_localEvent; 0 }; @@ -131,25 +131,38 @@ if (_context == 2) then { private _damageStructural = _unit getVariable [QGVAR($#structural), [0,0]]; + // Hitpoint spheres overlap, so a hit near a zone boundary lands inside several of them and the + // engine gives each the same damage divided by its own armor - meaning _realDamage ties exactly. + // Break that tie on sphere size: the smallest sphere containing the impact is the most specific + // location. This matters because HitPelvis is by far the largest torso sphere (0.24 vs 0.16-0.18) + // and no vanilla vest covers it, so without this a plated hit resolves to the unplated zone + // roughly half the time. Sort keys are [realDamage, -radius, newDamage, hitpoint], all descending + // Radii are per unit type and never change, so read them once + private _radii = GVAR(hitpointRadiusCache) getOrDefaultCall [typeOf _unit, { + TRACE_1("Radius cache miss",_this); + private _hitpointCfg = configOf _unit >> "HitPoints"; + createHashMapFromArray (["HitFace", "HitNeck", "HitHead", "HitPelvis", "HitAbdomen", "HitDiaphragm", "HitChest"] apply { + [_x, getNumber (_hitpointCfg >> _x >> "radius")] + }) + }, true]; + + private _fnc_worstHitpoint = { + private _candidates = _this apply { + private _hitPointName = _x; + (_unit getVariable [format [QGVAR($%1), _hitPointName], [0,0]]) params ["_real", "_new"]; + [_real, -(_radii getOrDefault [_hitPointName, 0]), _new, _hitPointName] + }; + _candidates sort false; + _candidates select 0 // return + }; + // --- Head - private _damageHead = [ - _unit getVariable [QGVAR($HitFace), [0,0]], - _unit getVariable [QGVAR($HitNeck), [0,0]], - _unit getVariable [QGVAR($HitHead), [0,0]] - ]; - _damageHead sort false; - _damageHead = _damageHead select 0; + // Keep the engine hitpoint that won, armor coverage varies a lot between them (e.g. helmets rarely cover HitNeck) + private _damageHead = ["HitFace", "HitNeck", "HitHead"] call _fnc_worstHitpoint; // --- Body - private _damageBody = [ - _unit getVariable [QGVAR($HitPelvis), [0,0]], - _unit getVariable [QGVAR($HitAbdomen), [0,0]], - _unit getVariable [QGVAR($HitDiaphragm), [0,0]], - _unit getVariable [QGVAR($HitChest), [0,0]] - // HitBody removed as it's a placeholder hitpoint and the high armor value (1000) throws the calculations off - ]; - _damageBody sort false; - _damageBody = _damageBody select 0; + // HitBody removed as it's a placeholder hitpoint and the high armor value (1000) throws the calculations off + private _damageBody = ["HitPelvis", "HitAbdomen", "HitDiaphragm", "HitChest"] call _fnc_worstHitpoint; // --- Arms and Legs private _damageLeftArm = _unit getVariable [QGVAR($HitLeftArm), [0,0]]; @@ -159,20 +172,20 @@ if (_context == 2) then { // Find hit point that received the maximum damage // Priority used for sorting if incoming damage is equal - // _realDamage, priority, _newDamage, body part name + // _realDamage, priority, _newDamage, body part name, engine hitpoint private _allDamages = [ - [_damageHead select 0, PRIORITY_HEAD, _damageHead select 1, "Head"], - [_damageBody select 0, PRIORITY_BODY, _damageBody select 1, "Body"], - [_damageLeftArm select 0, PRIORITY_LEFT_ARM, _damageLeftArm select 1, "LeftArm"], - [_damageRightArm select 0, PRIORITY_RIGHT_ARM, _damageRightArm select 1, "RightArm"], - [_damageLeftLeg select 0, PRIORITY_LEFT_LEG, _damageLeftLeg select 1, "LeftLeg"], - [_damageRightLeg select 0, PRIORITY_RIGHT_LEG, _damageRightLeg select 1, "RightLeg"], - [_damageStructural select 0, PRIORITY_STRUCTURAL, _damageStructural select 1, "#structural"] + [_damageHead select 0, PRIORITY_HEAD, _damageHead select 2, "Head", _damageHead select 3], + [_damageBody select 0, PRIORITY_BODY, _damageBody select 2, "Body", _damageBody select 3], + [_damageLeftArm select 0, PRIORITY_LEFT_ARM, _damageLeftArm select 1, "LeftArm", "HitLeftArm"], + [_damageRightArm select 0, PRIORITY_RIGHT_ARM, _damageRightArm select 1, "RightArm", "HitRightArm"], + [_damageLeftLeg select 0, PRIORITY_LEFT_LEG, _damageLeftLeg select 1, "LeftLeg", "HitLeftLeg"], + [_damageRightLeg select 0, PRIORITY_RIGHT_LEG, _damageRightLeg select 1, "RightLeg", "HitRightLeg"], + [_damageStructural select 0, PRIORITY_STRUCTURAL, _damageStructural select 1, "#structural", "#structural"] ]; TRACE_2("incoming",_allDamages,_damageStructural); _allDamages sort false; - _allDamages = _allDamages apply {[_x select 2, _x select 3, _x select 0]}; + _allDamages = _allDamages apply {[_x select 2, _x select 3, _x select 0, _x select 4]}; // Environmental damage sources all have empty ammo string // No explicit source given, we infer from differences between them diff --git a/addons/medical_engine/script_macros_medical.hpp b/addons/medical_engine/script_macros_medical.hpp index 2b238d26ca0..7f7197a7cb0 100644 --- a/addons/medical_engine/script_macros_medical.hpp +++ b/addons/medical_engine/script_macros_medical.hpp @@ -68,6 +68,27 @@ #define PENETRATION_THRESHOLD EGVAR(medical,const_penetrationThreshold) #define PENETRATION_THRESHOLD_DEFAULT 0.35 +// Armor plate classification, see EFUNC(medical_engine,getItemPlate) +// Vanilla armor items sit on an exact linear ladder: vests are 4*(level+1), headgear 2*(level+1), +// and vest passThrough is 0.6-0.1*level. Both encode the same level for every well formed item. +#define ARMOR_LEVEL_STEP_VEST 4 +#define ARMOR_LEVEL_STEP_HEADGEAR 2 +#define ARMOR_LEVEL_PASSTHROUGH_BASE 0.6 +#define ARMOR_LEVEL_PASSTHROUGH_STEP 0.1 +// How much the armor value is trusted over passThrough when the two disagree. +// They only disagree on malformed items (V_PlateCarrierGL_rgr is armor 78 / passThrough 0.6), +// so this only decides how those outliers rank. Note vanilla's own UI reads passThrough as +// ballistic protection and armor as explosive protection, hence not trusting either outright. +#define ARMOR_LEVEL_ARMOR_WEIGHT 0.75 +// Both go up to level V. Headgear uses its own thickness table rather than a lower cap, since +// high end helmets (Enhanced/Viper are Ops-Core XR class) do defeat rifle threats, just not the +// 7.62x51 ball that anchors level IV for torso plates. +#define MAX_PLATE_LEVEL 5 +#define MAX_HELMET_LEVEL 5 +// Uniforms sit on the same ladder, but their hitpoint armor is a coefficient of the unit's base +// armor rather than an absolute value, so it has to be multiplied out first. That puts CSAT +// fatigues at level II and Viper suits at level III, which is what they're meant to be + // To be replaced by a proper blood pressure calculation #define BLOOD_LOSS_KNOCK_OUT_THRESHOLD EGVAR(medical,const_bloodLossKnockOutThreshold) #define BLOOD_LOSS_KNOCK_OUT_THRESHOLD_DEFAULT 0.5 // 50% of cardiac output diff --git a/docs/wiki/framework/medical-framework.md b/docs/wiki/framework/medical-framework.md index 90e16148c3b..fcc7fcc126c 100644 --- a/docs/wiki/framework/medical-framework.md +++ b/docs/wiki/framework/medical-framework.md @@ -234,12 +234,12 @@ The damage elements are sorted in descending order according to how much damage Ammo can be a CfgAmmo classname (like `" B_556x45_Ball"`), empty string, or special ammo from `medical_engine`/another wound handler. Check if the passed ammo is within your expected values (like `!isNull (configFile >> "CfgAmmo" >> _ammo)` for CfgAmmo classes) before using it. ### Example -`[player, [[0.5, "Body", 1], [0.3, "Head", 0.6]], "grenade", "grenade_ammo"] ace_medical_damage_fnc_woundsHandlerBase` +`[player, [[0.5, "Body", 1, "HitChest"], [0.3, "Head", 0.6, "HitHead"]], "grenade", "grenade_ammo"] ace_medical_damage_fnc_woundsHandlerBase` | | Arguments | Explanation | | ---| --------- | ----------- | | 0 | `player` | Unit that was hit | -| 1 | `[[0.5, "Body", 1], [0.3, "Head", 0.6]]` | 0.5 damage to body (was 1 before armor), 0.3 damage to head (was 0.6 before armor) | +| 1 | `[[0.5, "Body", 1, "HitChest"], [0.3, "Head", 0.6, "HitHead"]]` | 0.5 damage to body (was 1 before armor), 0.3 damage to head (was 0.6 before armor), hitpoint that actually took the hit (may be nil or blank, use `params` with a default value and exit appropriately) | | 2 | `"grenade"` | type grenade (non-selection-specific) | | 3 | `"grenade_ammo"` | ammo |